diff options
| author | misskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com> | 2025-12-22 05:30:45 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-22 05:30:45 +0000 |
| commit | 0d46089f9a18abbb001fee2860dfaabf881831b3 (patch) | |
| tree | 8315f33781b790084279680d05ea521f47fe1219 /packages | |
| parent | Merge pull request #16972 from misskey-dev/develop (diff) | |
| parent | Release: 2025.12.2 (diff) | |
| download | misskey-0d46089f9a18abbb001fee2860dfaabf881831b3.tar.gz misskey-0d46089f9a18abbb001fee2860dfaabf881831b3.tar.bz2 misskey-0d46089f9a18abbb001fee2860dfaabf881831b3.zip | |
Merge pull request #16998 from misskey-dev/develop
Release: 2025.12.2
Diffstat (limited to 'packages')
44 files changed, 387 insertions, 219 deletions
diff --git a/packages/backend/package.json b/packages/backend/package.json index f49acff701..c7a8a6c223 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -71,25 +71,25 @@ "utf-8-validate": "6.0.5" }, "dependencies": { - "@aws-sdk/client-s3": "3.940.0", - "@aws-sdk/lib-storage": "3.940.0", + "@aws-sdk/client-s3": "3.948.0", + "@aws-sdk/lib-storage": "3.948.0", "@discordapp/twemoji": "16.0.1", - "@fastify/accepts": "5.0.3", - "@fastify/cors": "11.1.0", + "@fastify/accepts": "5.0.4", + "@fastify/cors": "11.2.0", "@fastify/express": "4.0.2", - "@fastify/http-proxy": "11.3.0", + "@fastify/http-proxy": "11.4.1", "@fastify/multipart": "9.3.0", "@fastify/static": "8.3.0", "@kitajs/html": "4.2.11", "@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/summaly": "5.2.5", - "@napi-rs/canvas": "0.1.83", + "@napi-rs/canvas": "0.1.84", "@nestjs/common": "11.1.9", "@nestjs/core": "11.1.9", "@nestjs/testing": "11.1.9", "@peertube/http-signature": "1.7.0", - "@sentry/node": "10.27.0", - "@sentry/profiling-node": "10.27.0", + "@sentry/node": "10.29.0", + "@sentry/profiling-node": "10.29.0", "@simplewebauthn/server": "13.2.2", "@sinonjs/fake-timers": "15.0.0", "@smithy/node-http-handler": "4.4.5", @@ -104,11 +104,11 @@ "bcryptjs": "3.0.3", "blurhash": "2.0.5", "body-parser": "2.2.1", - "bullmq": "5.65.0", + "bullmq": "5.65.1", "cacheable-lookup": "7.0.0", "chalk": "5.6.2", "chalk-template": "1.1.2", - "chokidar": "4.0.3", + "chokidar": "5.0.0", "color-convert": "3.1.3", "content-disposition": "1.0.1", "date-fns": "4.1.0", @@ -166,13 +166,13 @@ "slacc": "0.0.10", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", - "systeminformation": "5.27.11", + "systeminformation": "5.27.14", "tinycolor2": "1.6.0", "tmp": "0.2.5", "tsc-alias": "1.8.16", - "typeorm": "0.3.27", + "typeorm": "0.3.28", "typescript": "5.9.3", - "ulid": "3.0.1", + "ulid": "3.0.2", "vary": "1.1.2", "web-push": "3.6.7", "ws": "8.18.3", @@ -182,7 +182,7 @@ "@jest/globals": "29.7.0", "@kitajs/ts-html-plugin": "4.1.3", "@nestjs/platform-express": "11.1.9", - "@sentry/vue": "10.27.0", + "@sentry/vue": "10.29.0", "@simplewebauthn/types": "12.0.0", "@swc/jest": "0.2.39", "@types/accepts": "1.3.7", @@ -196,7 +196,7 @@ "@types/jsonld": "1.5.15", "@types/mime-types": "3.0.1", "@types/ms": "2.1.0", - "@types/node": "24.10.1", + "@types/node": "24.10.2", "@types/nodemailer": "7.0.4", "@types/oauth2orize": "1.11.5", "@types/oauth2orize-pkce": "0.1.2", @@ -207,7 +207,7 @@ "@types/rename": "1.0.7", "@types/sanitize-html": "2.16.0", "@types/semver": "7.7.1", - "@types/simple-oauth2": "5.0.7", + "@types/simple-oauth2": "5.0.8", "@types/sinonjs__fake-timers": "15.0.1", "@types/supertest": "6.0.3", "@types/tinycolor2": "1.4.6", @@ -215,13 +215,13 @@ "@types/vary": "1.1.3", "@types/web-push": "3.6.4", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.48.0", - "@typescript-eslint/parser": "8.48.0", + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", "aws-sdk-client-mock": "4.1.0", "cbor": "10.0.11", "cross-env": "10.1.0", "eslint-plugin-import": "2.32.0", - "execa": "9.6.0", + "execa": "9.6.1", "fkill": "10.0.1", "jest": "29.7.0", "jest-mock": "29.7.0", @@ -230,6 +230,6 @@ "pid-port": "2.0.0", "simple-oauth2": "5.1.0", "supertest": "7.1.4", - "vite": "7.2.4" + "vite": "7.2.7" } } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index f9852d3578..657d7869fa 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -30,6 +30,7 @@ type Source = { socket?: string; trustProxy?: FastifyServerOptions['trustProxy']; chmodSocket?: string; + enableIpRateLimit?: boolean; disableHsts?: boolean; db: { host: string; @@ -120,8 +121,9 @@ export type Config = { url: string; port: number; socket: string | undefined; - trustProxy: FastifyServerOptions['trustProxy']; + trustProxy: NonNullable<FastifyServerOptions['trustProxy']>; chmodSocket: string | undefined; + enableIpRateLimit: boolean; disableHsts: boolean | undefined; db: { host: string; @@ -263,9 +265,17 @@ export function loadConfig(): Config { url: url.origin, port: config.port ?? parseInt(process.env.PORT ?? '', 10), socket: config.socket, - trustProxy: config.trustProxy, + trustProxy: config.trustProxy ?? [ + '10.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16', + '127.0.0.1/32', + '::1/128', + 'fc00::/7', + ], chmodSocket: config.chmodSocket, disableHsts: config.disableHsts, + enableIpRateLimit: config.enableIpRateLimit ?? true, host, hostname, scheme, diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/AiService.ts index 7a005400bb..cbae280030 100644 --- a/packages/backend/src/core/AiService.ts +++ b/packages/backend/src/core/AiService.ts @@ -7,7 +7,6 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { Injectable } from '@nestjs/common'; -import si from 'systeminformation'; import { Mutex } from 'async-mutex'; import fetch from 'node-fetch'; import { bindThis } from '@/decorators.js'; @@ -84,6 +83,7 @@ export class AiService { @bindThis private async getCpuFlags(): Promise<string[]> { + const si = await import('systeminformation'); const str = await si.cpuFlags(); return str.split(/\s+/); } diff --git a/packages/backend/src/daemons/ServerStatsService.ts b/packages/backend/src/daemons/ServerStatsService.ts index d229efb123..a972e5861c 100644 --- a/packages/backend/src/daemons/ServerStatsService.ts +++ b/packages/backend/src/daemons/ServerStatsService.ts @@ -4,13 +4,12 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import si from 'systeminformation'; import Xev from 'xev'; import * as osUtils from 'os-utils'; import { bindThis } from '@/decorators.js'; -import type { OnApplicationShutdown } from '@nestjs/common'; import { MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; const ev = new Xev(); @@ -97,12 +96,14 @@ function cpuUsage(): Promise<number> { // MEMORY STAT async function mem() { + const si = await import('systeminformation'); const data = await si.mem(); return data; } // NETWORK STAT async function net() { + const si = await import('systeminformation'); const iface = await si.networkInterfaceDefault(); const data = await si.networkStats(iface); return data[0]; @@ -110,5 +111,6 @@ async function net() { // FS STAT async function fs() { + const si = await import('systeminformation'); return await si.disksIO().catch(() => ({ rIO_sec: 0, wIO_sec: 0 })); } diff --git a/packages/backend/src/misc/show-machine-info.ts b/packages/backend/src/misc/show-machine-info.ts index 8ddec35f23..b279eb9546 100644 --- a/packages/backend/src/misc/show-machine-info.ts +++ b/packages/backend/src/misc/show-machine-info.ts @@ -4,15 +4,11 @@ */ import * as os from 'node:os'; -import sysUtils from 'systeminformation'; import type Logger from '@/logger.js'; export async function showMachineInfo(parentLogger: Logger) { const logger = parentLogger.createSubLogger('machine'); logger.debug(`Hostname: ${os.hostname()}`); logger.debug(`Platform: ${process.platform} Arch: ${process.arch}`); - const mem = await sysUtils.mem(); - const totalmem = (mem.total / 1024 / 1024 / 1024).toFixed(1); - const availmem = (mem.available / 1024 / 1024 / 1024).toFixed(1); - logger.debug(`CPU: ${os.cpus().length} core MEM: ${totalmem}GB (available: ${availmem}GB)`); + logger.debug(`CPU: ${os.cpus().length} core MEM: ${(os.totalmem() / 1024 / 1024 / 1024).toFixed(1)}GB (available: ${(os.freemem() / 1024 / 1024 / 1024).toFixed(1)}GB)`); } diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 4e05322b12..ef9ac81f95 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -75,7 +75,7 @@ export class ServerService implements OnApplicationShutdown { @bindThis public async launch(): Promise<void> { const fastify = Fastify({ - trustProxy: this.config.trustProxy ?? false, + trustProxy: this.config.trustProxy, logger: false, }); this.#fastify = fastify; diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 27c79ab438..8bae46d9fb 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -313,11 +313,14 @@ export class ApiCallService implements OnApplicationShutdown { } if (ep.meta.limit) { - // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. - let limitActor: string; + let limitActor: string | null = null; if (user) { limitActor = user.id; - } else { + } else if (this.config.enableIpRateLimit) { + if (process.env.NODE_ENV === 'production' && (request.ip === '::1' || request.ip === '127.0.0.1')) { + this.logger.warn('Recieved API request from localhost IP address for rate limiting in production environment. This is likely due to an improper trustProxy setting in the config file.'); + } + limitActor = getIpHash(request.ip); } @@ -330,7 +333,7 @@ export class ApiCallService implements OnApplicationShutdown { // TODO: 毎リクエスト計算するのもあれだしキャッシュしたい const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1; - if (factor > 0) { + if (limitActor != null && factor > 0) { // Rate limit const rateLimit = await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor); if (rateLimit != null) { diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index 3e889372d8..00e8828242 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -15,6 +15,7 @@ import type { UserSecurityKeysRepository, UsersRepository, } from '@/models/_.js'; +import type Logger from '@/logger.js'; import type { Config } from '@/config.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import type { MiLocalUser } from '@/models/User.js'; @@ -23,6 +24,7 @@ import { bindThis } from '@/decorators.js'; import { WebAuthnService } from '@/core/WebAuthnService.js'; import { UserAuthService } from '@/core/UserAuthService.js'; import { CaptchaService } from '@/core/CaptchaService.js'; +import { LoggerService } from '@/core/LoggerService.js'; import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { RateLimiterService } from './RateLimiterService.js'; import { SigninService } from './SigninService.js'; @@ -31,6 +33,8 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; @Injectable() export class SigninApiService { + private logger: Logger; + constructor( @Inject(DI.config) private config: Config, @@ -50,6 +54,7 @@ export class SigninApiService { @Inject(DI.signinsRepository) private signinsRepository: SigninsRepository, + private loggerService: LoggerService, private idService: IdService, private rateLimiterService: RateLimiterService, private signinService: SigninService, @@ -57,6 +62,7 @@ export class SigninApiService { private webAuthnService: WebAuthnService, private captchaService: CaptchaService, ) { + this.logger = this.loggerService.getLogger('Signin'); } @bindThis @@ -90,16 +96,21 @@ export class SigninApiService { } // not more than 1 attempt per second and not more than 10 attempts per hour - const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip)); - if (rateLimit != null) { - reply.code(429); - return { - error: { - message: 'Too many failed attempts to sign in. Try again later.', - code: 'TOO_MANY_AUTHENTICATION_FAILURES', - id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', - }, - }; + if (this.config.enableIpRateLimit) { + if (process.env.NODE_ENV === 'production' && (request.ip === '::1' || request.ip === '127.0.0.1')) { + this.logger.warn('Recieved signin request from localhost IP address for rate limiting in production environment. This is likely due to an improper trustProxy setting in the config file.'); + } + const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip)); + if (rateLimit != null) { + reply.code(429); + return { + error: { + message: 'Too many failed attempts to sign in. Try again later.', + code: 'TOO_MANY_AUTHENTICATION_FAILURES', + id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', + }, + }; + } } if (typeof username !== 'string') { diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts index 9ba23c54e2..920f9d0b3a 100644 --- a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts +++ b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts @@ -84,19 +84,25 @@ export class SigninWithPasskeyApiService { return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); }; - try { + if (this.config.enableIpRateLimit) { + if (process.env.NODE_ENV === 'production' && (request.ip === '::1' || request.ip === '127.0.0.1')) { + this.logger.warn('Recieved signin with passkey request from localhost IP address for rate limiting in production environment. This is likely due to an improper trustProxy setting in the config file.'); + } + + try { // 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) { - reply.code(429); - return { - error: { - message: 'Too many failed attempts to sign in. Try again later.', - code: 'TOO_MANY_AUTHENTICATION_FAILURES', - id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', - }, - }; + await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip)); + } catch (err) { + reply.code(429); + return { + error: { + message: 'Too many failed attempts to sign in. Try again later.', + code: 'TOO_MANY_AUTHENTICATION_FAILURES', + id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', + }, + }; + } } // Initiate Passkey Auth challenge with context diff --git a/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts index f3e440b4cb..86158d7e22 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts @@ -52,18 +52,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- super(meta, paramDef, async (ps, me) => { const jobs = await this.deliverQueue.getJobs(['delayed']); - const res = [] as [string, number][]; + const counts = new Map<string, number>(); for (const job of jobs) { const host = new URL(job.data.to).host; - if (res.find(x => x[0] === host)) { - res.find(x => x[0] === host)![1]++; - } else { - res.push([host, 1]); - } + counts.set(host, (counts.get(host) ?? 0) + 1); } - res.sort((a, b) => b[1] - a[1]); + const res = [...counts.entries()].sort((a, b) => b[1] - a[1]); return res; }); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts index e7589cba81..ad6a823b8f 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts @@ -52,18 +52,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- super(meta, paramDef, async (ps, me) => { const jobs = await this.inboxQueue.getJobs(['delayed']); - const res = [] as [string, number][]; + const counts = new Map<string, number>(); for (const job of jobs) { const host = new URL(job.data.signature.keyId).host; - if (res.find(x => x[0] === host)) { - res.find(x => x[0] === host)![1]++; - } else { - res.push([host, 1]); - } + counts.set(host, (counts.get(host) ?? 0) + 1); } - res.sort((a, b) => b[1] - a[1]); + const res = [...counts.entries()].sort((a, b) => b[1] - a[1]); return res; }); diff --git a/packages/backend/src/server/api/endpoints/admin/server-info.ts b/packages/backend/src/server/api/endpoints/admin/server-info.ts index 80b6a4d32e..603be514c8 100644 --- a/packages/backend/src/server/api/endpoints/admin/server-info.ts +++ b/packages/backend/src/server/api/endpoints/admin/server-info.ts @@ -4,7 +4,6 @@ */ import * as os from 'node:os'; -import si from 'systeminformation'; import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import * as Redis from 'ioredis'; @@ -112,6 +111,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- ) { super(meta, paramDef, async () => { + const si = await import('systeminformation'); + const memStats = await si.mem(); const fsStats = await si.fsSize(); const netInterface = await si.networkInterfaceDefault(); diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts index 8301c85f2e..0e8dc73ad9 100644 --- a/packages/backend/src/server/api/endpoints/server-info.ts +++ b/packages/backend/src/server/api/endpoints/server-info.ts @@ -4,7 +4,6 @@ */ import * as os from 'node:os'; -import si from 'systeminformation'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { MiMeta } from '@/models/_.js'; @@ -93,6 +92,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- }, }; + const si = await import('systeminformation'); + const memStats = await si.mem(); const fsStats = await si.fsSize(); diff --git a/packages/frontend-builder/package.json b/packages/frontend-builder/package.json index 36c32b915d..c1d9e316e6 100644 --- a/packages/frontend-builder/package.json +++ b/packages/frontend-builder/package.json @@ -11,9 +11,9 @@ }, "devDependencies": { "@types/estree": "1.0.8", - "@types/node": "24.10.1", - "@typescript-eslint/eslint-plugin": "8.48.0", - "@typescript-eslint/parser": "8.48.0", + "@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" }, @@ -21,6 +21,6 @@ "i18n": "workspace:*", "estree-walker": "3.0.3", "magic-string": "0.30.21", - "vite": "7.2.4" + "vite": "7.2.7" } } diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index e82cdc1f27..808559f44a 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -26,11 +26,11 @@ "misskey-js": "workspace:*", "punycode.js": "2.3.1", "rollup": "4.53.3", - "sass": "1.94.2", - "shiki": "3.17.0", + "sass": "1.95.1", + "shiki": "3.19.0", "tinycolor2": "1.6.0", "uuid": "13.0.0", - "vite": "7.2.4", + "vite": "7.2.7", "vue": "3.5.25" }, "devDependencies": { @@ -39,13 +39,13 @@ "@testing-library/vue": "8.1.0", "@types/estree": "1.0.8", "@types/micromatch": "4.0.10", - "@types/node": "24.10.1", + "@types/node": "24.10.2", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/tinycolor2": "1.4.6", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.48.0", - "@typescript-eslint/parser": "8.48.0", - "@vitest/coverage-v8": "4.0.14", + "@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", "cross-env": "10.1.0", @@ -54,15 +54,15 @@ "happy-dom": "20.0.11", "intersection-observer": "0.12.2", "micromatch": "4.0.8", - "msw": "2.12.3", + "msw": "2.12.4", "nodemon": "3.1.11", - "prettier": "3.7.1", + "prettier": "3.7.4", "start-server-and-test": "2.1.3", - "tsx": "4.20.6", + "tsx": "4.21.0", "typescript": "5.9.3", "vite-plugin-turbosnap": "1.0.3", - "vue-component-type-helpers": "3.1.5", + "vue-component-type-helpers": "3.1.8", "vue-eslint-parser": "10.2.0", - "vue-tsc": "3.1.5" + "vue-tsc": "3.1.8" } } diff --git a/packages/frontend-shared/js/emojilist.ts b/packages/frontend-shared/js/emojilist.ts index 20ddd0f7d7..1cee7173bd 100644 --- a/packages/frontend-shared/js/emojilist.ts +++ b/packages/frontend-shared/js/emojilist.ts @@ -41,7 +41,7 @@ export const emojiCharByCategory = _charGroupByCategory; export function getUnicodeEmojiOrNull(char: string): UnicodeEmojiDef | null { // Colorize it because emojilist.json assumes that - return unicodeEmojisMap.get(colorizeEmoji(char)) + return unicodeEmojisMap.get(forceColorizeEmoji(char)) // カラースタイル絵文字がjsonに無い場合はテキストスタイル絵文字にフォールバックする ?? unicodeEmojisMap.get(char) // それでも見つからない場合はnullを返す @@ -54,12 +54,12 @@ export function getUnicodeEmoji(char: string): UnicodeEmojiDef | string { } export function isSupportedEmoji(char: string): boolean { - return unicodeEmojisMap.has(colorizeEmoji(char)) || unicodeEmojisMap.has(char); + return unicodeEmojisMap.has(forceColorizeEmoji(char)) || unicodeEmojisMap.has(char); } export function getEmojiName(char: string): string { // Colorize it because emojilist.json assumes that - const idx = _indexByChar.get(colorizeEmoji(char)) ?? _indexByChar.get(char); + const idx = _indexByChar.get(forceColorizeEmoji(char)) ?? _indexByChar.get(char); if (idx === undefined) { // 絵文字情報がjsonに無い場合は名前の取得が出来ないのでそのまま返すしか無い return char; @@ -72,7 +72,24 @@ export function getEmojiName(char: string): string { * テキストスタイル絵文字(U+260Eなどの1文字で表現される絵文字)をカラースタイル絵文字に変換します(VS16:U+FE0Fを付与)。 */ export function colorizeEmoji(char: string) { - return char.length === 1 ? `${char}\uFE0F` : char; + // <文字列>.length はコードポイント数ではなくUTF-16コードユニット数を返すため、サロゲートペアを含む絵文字で誤動作する。 + // そのため、配列に変換してコードポイント数を数える方法を取る。 + return Array.from(char).length === 1 ? `${char}\uFE0F` : char; +} + +/** + * 文字種にかかわらず、カラースタイル絵文字への変換を試みます(本ファイルにある検索プログラム用・フォールバックが必須)。 + */ +function forceColorizeEmoji(char: string) { + // <文字列>.length はコードポイント数ではなくUTF-16コードユニット数を返すため、サロゲートペアを含む絵文字で誤動作する。 + // そのため、配列に変換してコードポイント数を数える方法を取る。 + const chars = Array.from(char); + if (chars.includes('\uFE0F')) { + return char; + } else { + chars.splice(1, 0, '\uFE0F'); + return chars.join(''); + } } export interface CustomEmojiFolderTree { diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json index baf5c561d8..49cce0d707 100644 --- a/packages/frontend-shared/package.json +++ b/packages/frontend-shared/package.json @@ -21,10 +21,10 @@ "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { - "@types/node": "24.10.1", - "@typescript-eslint/eslint-plugin": "8.48.0", - "@typescript-eslint/parser": "8.48.0", - "esbuild": "0.27.0", + "@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", diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 68dc5bd656..730bf71789 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -19,18 +19,18 @@ "@analytics/google-analytics": "1.1.0", "@discordapp/twemoji": "16.0.1", "@github/webauthn-json": "2.1.1", - "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", + "@mcaptcha/vanilla-glue": "0.1.0-rc2", "i18n": "workspace:*", "@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.27.0", + "@sentry/vue": "10.29.0", "@syuilo/aiscript": "1.2.0", "@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0", "@twemoji/parser": "16.0.0", "@vitejs/plugin-vue": "6.0.2", - "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15", + "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.16", "analytics": "0.8.19", "broadcast-channel": "7.2.0", "buraha": "0.0.1", @@ -45,8 +45,8 @@ "cropperjs": "2.1.0", "date-fns": "4.1.0", "eventemitter3": "5.0.1", - "execa": "9.6.0", - "exifreader": "4.32.0", + "execa": "9.6.1", + "exifreader": "4.33.1", "frontend-shared": "workspace:*", "icons-subsetter": "workspace:*", "idb-keyval": "6.2.2", @@ -55,7 +55,7 @@ "is-file-animated": "1.0.2", "json5": "2.2.3", "matter-js": "0.20.0", - "mediabunny": "1.25.3", + "mediabunny": "1.25.8", "mfm-js": "0.25.0", "misskey-bubble-game": "workspace:*", "misskey-js": "workspace:*", @@ -66,14 +66,14 @@ "qr-scanner": "1.4.2", "rollup": "4.53.3", "sanitize-html": "2.17.0", - "sass": "1.94.2", - "shiki": "3.17.0", + "sass": "1.95.1", + "shiki": "3.19.0", "textarea-caret": "3.1.0", "three": "0.181.2", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", "v-code-diff": "1.13.1", - "vite": "7.2.4", + "vite": "7.2.7", "vue": "3.5.25", "vuedraggable": "next", "wanakana": "5.3.1" @@ -82,7 +82,7 @@ "@misskey-dev/summaly": "5.2.5", "@storybook/addon-essentials": "8.6.14", "@storybook/addon-interactions": "8.6.14", - "@storybook/addon-links": "10.1.0", + "@storybook/addon-links": "10.1.5", "@storybook/addon-mdx-gfm": "8.6.14", "@storybook/addon-storysource": "8.6.14", "@storybook/blocks": "8.6.14", @@ -90,33 +90,33 @@ "@storybook/core-events": "8.6.14", "@storybook/manager-api": "8.6.14", "@storybook/preview-api": "8.6.14", - "@storybook/react": "10.1.0", - "@storybook/react-vite": "10.1.0", + "@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.0", - "@storybook/vue3-vite": "10.1.0", + "@storybook/vue3": "10.1.5", + "@storybook/vue3-vite": "10.1.5", "@tabler/icons-webfont": "3.35.0", "@testing-library/vue": "8.1.0", "@types/canvas-confetti": "1.9.0", "@types/estree": "1.0.8", "@types/matter-js": "0.20.2", "@types/micromatch": "4.0.10", - "@types/node": "24.10.1", + "@types/node": "24.10.2", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/sanitize-html": "2.16.0", "@types/seedrandom": "3.0.8", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", - "@typescript-eslint/eslint-plugin": "8.48.0", - "@typescript-eslint/parser": "8.48.0", - "@vitest/coverage-v8": "4.0.14", + "@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", "astring": "1.9.0", "cross-env": "10.1.0", - "cypress": "15.7.0", + "cypress": "15.7.1", "eslint-plugin-import": "2.32.0", "eslint-plugin-vue": "10.6.2", "estree-walker": "3.0.3", @@ -125,24 +125,24 @@ "magic-string": "0.30.21", "micromatch": "4.0.8", "minimatch": "10.1.1", - "msw": "2.12.3", + "msw": "2.12.4", "msw-storybook-addon": "2.0.6", "nodemon": "3.1.11", - "prettier": "3.7.1", - "react": "19.2.0", - "react-dom": "19.2.0", + "prettier": "3.7.4", + "react": "19.2.1", + "react-dom": "19.2.1", "seedrandom": "3.0.5", "start-server-and-test": "2.1.3", - "storybook": "10.1.0", + "storybook": "10.1.5", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", - "tsx": "4.20.6", + "tsx": "4.21.0", "typescript": "5.9.3", - "vite-plugin-glsl": "1.5.4", + "vite-plugin-glsl": "1.5.5", "vite-plugin-turbosnap": "1.0.3", - "vitest": "4.0.14", + "vitest": "4.0.15", "vitest-fetch-mock": "0.4.5", - "vue-component-type-helpers": "3.1.5", + "vue-component-type-helpers": "3.1.8", "vue-eslint-parser": "10.2.0", - "vue-tsc": "3.1.5" + "vue-tsc": "3.1.8" } } diff --git a/packages/frontend/src/components/MkImgPreviewDialog.vue b/packages/frontend/src/components/MkImgPreviewDialog.vue index 3e6e4e0ec9..e17a1651cf 100644 --- a/packages/frontend/src/components/MkImgPreviewDialog.vue +++ b/packages/frontend/src/components/MkImgPreviewDialog.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkModalWindow> </template> <script lang="ts" setup> -import { defineProps, ref } from 'vue'; +import { ref } from 'vue'; import MkModalWindow from './MkModalWindow.vue'; import type * as Misskey from 'misskey-js'; diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 86557b12df..b3bcfcc137 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div - :class="[$style.root, { [$style.modal]: modal, _popup: modal }]" + :class="[$style.root]" @dragover.stop="onDragover" @dragenter="onDragenter" @dragleave="onDragleave" @@ -114,7 +114,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, onUnmounted } from 'vue'; +import { watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, onUnmounted } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; @@ -161,8 +161,6 @@ import { closeTip } from '@/tips.js'; const $i = ensureSignin(); -const modal = inject(DI.inModal, false); - const props = withDefaults(defineProps<PostFormProps & { fixed?: boolean; autofocus?: boolean; @@ -1447,13 +1445,6 @@ defineExpose({ .root { position: relative; container-type: inline-size; - - &.modal { - width: 100%; - max-width: 520px; - overflow-x: clip; - overflow-y: auto; - } } //#region header @@ -1722,7 +1713,8 @@ html[data-color-scheme=light] .preview { min-width: 100%; width: 100%; min-height: 90px; - height: 100%; + max-height: 500px; + field-sizing: content; } .textCount { diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index ba8d3a7210..a7cf8a37cf 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPostForm ref="form" :class="$style.form" + class="_popup" v-bind="props" autofocus freezeAfterPosted @@ -73,7 +74,8 @@ function onModalClosed() { <style lang="scss" module> .form { - max-height: 100%; + width: 100%; + max-width: 520px; margin: 0 auto auto auto; } </style> diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index c2548cc7be..e7208ed574 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="showDecoration"> <img v-for="decoration in decorations ?? user.avatarDecorations" - :class="[$style.decoration, { [$style.decorationBlink]: decoration.blink }]" + :class="[$style.decoration, { [$style.decorationBlink]: getDecorationIsBrink(decoration) }]" :src="getDecorationUrl(decoration)" :style="{ rotate: getDecorationAngle(decoration), @@ -56,13 +56,16 @@ import { prefer } from '@/preferences.js'; const animation = ref(prefer.s.animation); const squareAvatars = ref(prefer.s.squareAvatars); +type Decoration = Misskey.entities.UserDetailed['avatarDecorations'][number]; +type DecorationEditorDecoration = Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'> & { blink?: boolean; }; + const props = withDefaults(defineProps<{ user: Misskey.entities.User; target?: string | null; link?: boolean; preview?: boolean; indicator?: boolean; - decorations?: (Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'> & { blink?: boolean; })[]; + decorations?: DecorationEditorDecoration[]; forceShowDecoration?: boolean; }>(), { target: null, @@ -93,27 +96,31 @@ function onClick(ev: MouseEvent): void { emit('click', ev); } -function getDecorationUrl(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { +function getDecorationUrl(decoration: Decoration | DecorationEditorDecoration) { if (prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar) return getStaticImageUrl(decoration.url); return decoration.url; } -function getDecorationAngle(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { +function getDecorationAngle(decoration: Decoration | DecorationEditorDecoration) { const angle = decoration.angle ?? 0; return angle === 0 ? undefined : `${angle * 360}deg`; } -function getDecorationScale(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { +function getDecorationScale(decoration: Decoration | DecorationEditorDecoration) { const scaleX = decoration.flipH ? -1 : 1; return scaleX === 1 ? undefined : `${scaleX} 1`; } -function getDecorationOffset(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { +function getDecorationOffset(decoration: Decoration | DecorationEditorDecoration) { const offsetX = decoration.offsetX ?? 0; const offsetY = decoration.offsetY ?? 0; return offsetX === 0 && offsetY === 0 ? undefined : `${offsetX * 100}% ${offsetY * 100}%`; } +function getDecorationIsBrink(decoration: Decoration | DecorationEditorDecoration) { + return 'blink' in decoration && decoration.blink === true; +} + const color = ref<string | undefined>(); watch(() => props.user.avatarBlurhash, () => { diff --git a/packages/frontend/src/pages/admin/performance.vue b/packages/frontend/src/pages/admin/performance.vue index c5f3c2d4f0..3ce8e05982 100644 --- a/packages/frontend/src/pages/admin/performance.vue +++ b/packages/frontend/src/pages/admin/performance.vue @@ -122,7 +122,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker> <MkFolder :defaultOpen="true"> <template #icon><SearchIcon><i class="ti ti-bolt"></i></SearchIcon></template> - <template #label><SearchLabel>Misskey® Reactions Boost Technology™ (RBT)</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #label><SearchLabel>Misskey® Reactions Boost Technology™ (RBT)</SearchLabel></template> <template v-if="rbtForm.savedState.enableReactionsBuffering" #suffix>Enabled</template> <template v-else #suffix>Disabled</template> <template v-if="rbtForm.modified.value" #footer> diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue index 2913aaae64..efc9ee014f 100644 --- a/packages/frontend/src/pages/flash/flash.vue +++ b/packages/frontend/src/pages/flash/flash.vue @@ -193,7 +193,7 @@ function start() { } function getIsLegacy(version: string | null): boolean { - if (version == null) return false; + if (version == null) return true; try { return compareVersions(version, '1.0.0') < 0; } catch { @@ -206,7 +206,7 @@ async function run() { if (!flash.value) return; const version = utils.getLangVersion(flash.value.script); - const isLegacy = version != null && getIsLegacy(version); + const isLegacy = getIsLegacy(version); const { Interpreter, Parser, values } = isLegacy ? (await import('@syuilo/aiscript-0-19-0') as any) : await import('@syuilo/aiscript'); diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index 250c1735be..39c32d347f 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -11,6 +11,11 @@ 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"> + <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> + </MkInfo> <MkInfo v-if="!store.r.enablePreferencesAutoCloudBackup.value && store.r.showPreferencesAutoCloudBackupSuggestion.value" class="info"> <div>{{ i18n.ts._preferencesBackup.autoPreferencesBackupIsNotEnabledForThisDevice }}</div> <div><button class="_textButton" @click="enableAutoBackup">{{ i18n.ts.enable }}</button> | <button class="_textButton" @click="skipAutoBackup">{{ i18n.ts.skip }}</button></div> @@ -46,6 +51,7 @@ 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'; const searchIndex = await import('search-index:settings').then(({ searchIndexes }) => genSearchIndexes(searchIndexes)); diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index e6ee3bfb1c..d4097bde94 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -142,6 +142,8 @@ SPDX-License-Identifier: AGPL-3.0-only <hr> </template> + <MkButton v-if="!storagePersisted" @click="enableStoragePersistence">{{ i18n.ts._settings.settingsPersistence_title }}</MkButton> + <MkButton @click="forceCloudBackup">{{ i18n.ts._preferencesBackup.forceBackup }}</MkButton> <FormSlot> @@ -163,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 { misskeyApi } from '@/utility/misskey-api.js'; +import { enableStoragePersistence, storagePersisted, skipStoragePersistence } from '@/utility/storage.js'; import { ensureSignin } from '@/i.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index c2e0b3fe41..edc71e5156 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -90,7 +90,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['lockdown']"> <FormSection> - <template #label><SearchLabel>{{ i18n.ts.lockdown }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #label><SearchLabel>{{ i18n.ts.lockdown }}</SearchLabel></template> <div class="_gaps_m"> <SearchMarker :keywords="['login', 'signin']"> @@ -213,9 +213,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed, watch } from 'vue'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkSelect from '@/components/MkSelect.vue'; -import type { MkSelectItem } from '@/components/MkSelect.vue'; import FormSection from '@/components/form/section.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 89325dee63..7d3da470d6 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -107,7 +107,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['follow', 'message']"> <MkInput v-model="profile.followedMessage" :max="200" manualSave :mfmPreview="false"> - <template #label><SearchLabel>{{ i18n.ts._profile.followedMessage }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #label><SearchLabel>{{ i18n.ts._profile.followedMessage }}</SearchLabel></template> <template #caption> <div><SearchText>{{ i18n.ts._profile.followedMessageDescription }}</SearchText></div> <div>{{ i18n.ts._profile.followedMessageDescriptionForLockedAccount }}</div> diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue index bc77c1f0af..30f97b095e 100644 --- a/packages/frontend/src/pages/settings/security.vue +++ b/packages/frontend/src/pages/settings/security.vue @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['signin', 'login', 'history', 'log']"> <FormSection> <template #label><SearchLabel>{{ i18n.ts.signinHistory }}</SearchLabel></template> - <MkPagination :paginator="paginator" withControl> + <MkPagination :paginator="paginator" withControl :forceDisableInfiniteScroll="true"> <template #default="{items}"> <div> <div v-for="item in items" :key="item.id" v-panel class="timnmucd"> diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 5949ee71eb..13ba0000e4 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -257,20 +257,23 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> { this.rewriteRawState(key, v); - this.emit('committed', { - key, - value: v, - oldValue: this.s[key], - }); - const record = this.getMatchedRecordOf(key); + const _save = () => { + this.save(); + this.emit('committed', { + key, + value: v, + oldValue: this.s[key], + }); + }; + if (parseScope(record[0]).account == null && isAccountDependentKey(key) && currentAccount != null) { this.profile.preferences[key].push([makeScope({ server: host, account: currentAccount.id, }), v, {}]); - this.save(); + _save(); return; } @@ -278,12 +281,12 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> { this.profile.preferences[key].push([makeScope({ server: host, }), v, {}]); - this.save(); + _save(); return; } record[1] = v; - this.save(); + _save(); if (record[2].sync) { // awaitの必要なし diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 073fbba0fb..fb9349c42f 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -118,6 +118,10 @@ export const store = markRaw(new Pizzax('base', { where: 'device', default: true, }, + showStoragePersistenceSuggestion: { + where: 'device', + default: true, + }, //#region TODO: そのうち消す (preferに移行済み) defaultWithReplies: { diff --git a/packages/frontend/src/tips.ts b/packages/frontend/src/tips.ts index 8a58e2aa63..6ee7130ee9 100644 --- a/packages/frontend/src/tips.ts +++ b/packages/frontend/src/tips.ts @@ -12,6 +12,7 @@ export const TIPS = [ 'clips', 'userLists', 'postForm', + 'deck', 'tl.home', 'tl.local', 'tl.social', diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index f37e7ae85e..c679ee7a92 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -14,6 +14,9 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-if="store.r.realtimeMode.value" class="ti ti-bolt ti-fw"></i> <i v-else class="ti ti-bolt-off ti-fw"></i> </button> + <button v-if="!iconOnly && showWidgetButton" v-tooltip.noDelay.right="i18n.ts.widgets" class="_button" :class="[$style.widget]" @click="() => emit('widgetButtonClick')"> + <i class="ti ti-apps ti-fw"></i> + </button> </div> <div :class="$style.middle"> <MkA v-tooltip.noDelay.right="i18n.ts.timeline" :class="$style.item" :activeClass="$style.active" to="/" exact> @@ -51,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkA> </div> <div :class="$style.bottom"> - <button v-if="showWidgetButton" v-tooltip.noDelay.right="i18n.ts.widgets" class="_button" :class="[$style.widget]" @click="() => emit('widgetButtonClick')"> + <button v-if="iconOnly && showWidgetButton" v-tooltip.noDelay.right="i18n.ts.widgets" class="_button" :class="[$style.widget]" @click="() => emit('widgetButtonClick')"> <i class="ti ti-apps ti-fw"></i> </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"> @@ -436,6 +439,12 @@ function menuEdit() { } } + .widget { + display: inline-block; + width: var(--top-height); + margin-left: auto; + } + .bottom { position: sticky; bottom: 0; diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index 0941c25467..484b7f277a 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -38,36 +38,39 @@ SPDX-License-Identifier: AGPL-3.0-only @headerWheel="onWheel" /> </section> - <div v-if="layout.length === 0" class="_panel" :class="$style.onboarding"> + <div v-if="layout.length === 0" class="_panel _gaps" :class="$style.onboarding"> <div>{{ i18n.ts._deck.introduction }}</div> <div>{{ i18n.ts._deck.introduction2 }}</div> + <MkInfo v-if="!store.r.tips.value.deck" closable @close="closeTip('deck')"> + <button class="_textButton" @click="showTour">{{ i18n.ts._deck.showHowToUse }}</button> + </MkInfo> </div> </div> <div v-if="prefer.r['deck.menuPosition'].value === 'right'" :class="$style.sideMenu"> <div :class="$style.sideMenuTop"> - <button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${prefer.s['deck.profile']}`" :class="$style.sideMenuButton" class="_button" @click="switchProfileMenu"><i class="ti ti-caret-down"></i></button> + <button ref="swicthProfileButtonEl" v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${prefer.s['deck.profile']}`" :class="$style.sideMenuButton" class="_button" @click="switchProfileMenu"><i class="ti ti-caret-down"></i></button> <button v-tooltip.noDelay.left="i18n.ts._deck.deleteProfile" :class="$style.sideMenuButton" class="_button" @click="deleteProfile"><i class="ti ti-trash"></i></button> </div> <div :class="$style.sideMenuMiddle"> - <button v-tooltip.noDelay.left="i18n.ts._deck.addColumn" :class="$style.sideMenuButton" class="_button" @click="addColumn"><i class="ti ti-plus"></i></button> + <button ref="addColumnButtonEl" v-tooltip.noDelay.left="i18n.ts._deck.addColumn" :class="$style.sideMenuButton" class="_button" @click="addColumn"><i class="ti ti-plus"></i></button> </div> <div :class="$style.sideMenuBottom"> - <button v-tooltip.noDelay.left="i18n.ts.settings" :class="$style.sideMenuButton" class="_button" @click="showSettings"><i class="ti ti-settings-2"></i></button> + <button ref="settingsButtonEl" v-tooltip.noDelay.left="i18n.ts.settings" :class="$style.sideMenuButton" class="_button" @click="showSettings"><i class="ti ti-settings-2"></i></button> </div> </div> </div> <div v-if="prefer.r['deck.menuPosition'].value === 'bottom'" :class="$style.bottomMenu"> <div :class="$style.bottomMenuLeft"> - <button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${prefer.s['deck.profile']}`" :class="$style.bottomMenuButton" class="_button" @click="switchProfileMenu"><i class="ti ti-caret-down"></i></button> - <button v-tooltip.noDelay.left="i18n.ts._deck.deleteProfile" :class="$style.bottomMenuButton" class="_button" @click="deleteProfile"><i class="ti ti-trash"></i></button> + <button ref="swicthProfileButtonEl" v-tooltip.noDelay.top="`${i18n.ts._deck.profile}: ${prefer.s['deck.profile']}`" :class="$style.bottomMenuButton" class="_button" @click="switchProfileMenu"><i class="ti ti-caret-down"></i></button> + <button v-tooltip.noDelay.top="i18n.ts._deck.deleteProfile" :class="$style.bottomMenuButton" class="_button" @click="deleteProfile"><i class="ti ti-trash"></i></button> </div> <div :class="$style.bottomMenuMiddle"> - <button v-tooltip.noDelay.left="i18n.ts._deck.addColumn" :class="$style.bottomMenuButton" class="_button" @click="addColumn"><i class="ti ti-plus"></i></button> + <button ref="addColumnButtonEl" v-tooltip.noDelay.top="i18n.ts._deck.addColumn" :class="$style.bottomMenuButton" class="_button" @click="addColumn"><i class="ti ti-plus"></i></button> </div> <div :class="$style.bottomMenuRight"> - <button v-tooltip.noDelay.left="i18n.ts.settings" :class="$style.bottomMenuButton" class="_button" @click="showSettings"><i class="ti ti-settings-2"></i></button> + <button ref="settingsButtonEl" v-tooltip.noDelay.top="i18n.ts.settings" :class="$style.bottomMenuButton" class="_button" @click="showSettings"><i class="ti ti-settings-2"></i></button> </div> </div> @@ -96,6 +99,7 @@ import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import { deviceKind } from '@/utility/device-kind.js'; import { prefer } from '@/preferences.js'; +import { store } from '@/store.js'; import XMainColumn from '@/ui/deck/main-column.vue'; import XTlColumn from '@/ui/deck/tl-column.vue'; import XAntennaColumn from '@/ui/deck/antenna-column.vue'; @@ -107,10 +111,13 @@ import XMentionsColumn from '@/ui/deck/mentions-column.vue'; import XDirectColumn from '@/ui/deck/direct-column.vue'; import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; import XChatColumn from '@/ui/deck/chat-column.vue'; +import MkInfo from '@/components/MkInfo.vue'; import { mainRouter } from '@/router.js'; import { columns, layout, columnTypes, switchProfileMenu, addColumn as addColumnToStore, deleteProfile as deleteProfile_ } from '@/deck.js'; import { shouldSuggestRestoreBackup } from '@/preferences/utility.js'; import { shouldSuggestReload } from '@/utility/reload-suggest.js'; +import { startTour } from '@/utility/tour.js'; +import { closeTip } from '@/tips.js'; const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); @@ -163,6 +170,9 @@ function showSettings() { } const columnsEl = useTemplateRef('columnsEl'); +const addColumnButtonEl = useTemplateRef('addColumnButtonEl'); +const settingsButtonEl = useTemplateRef('settingsButtonEl'); +const swicthProfileButtonEl = useTemplateRef('swicthProfileButtonEl'); const addColumn = async (ev) => { const { canceled, result: column } = await os.select({ @@ -218,6 +228,30 @@ async function deleteProfile() { os.success(); } +function showTour() { + if (addColumnButtonEl.value == null || + settingsButtonEl.value == null || + swicthProfileButtonEl.value == null) { + return; + } + + startTour([{ + element: addColumnButtonEl.value, + title: i18n.ts._deck._howToUse.addColumn_title, + description: i18n.ts._deck._howToUse.addColumn_description, + }, { + element: settingsButtonEl.value, + title: i18n.ts._deck._howToUse.settings_title, + description: i18n.ts._deck._howToUse.settings_description, + }, { + element: swicthProfileButtonEl.value, + title: i18n.ts._deck._howToUse.switchProfile_title, + description: i18n.ts._deck._howToUse.switchProfile_description, + }]).then(() => { + closeTip('deck'); + }); +} + window.document.documentElement.style.overflowY = 'hidden'; window.document.documentElement.style.scrollBehavior = 'auto'; </script> @@ -345,7 +379,7 @@ window.document.documentElement.style.scrollBehavior = 'auto'; } .bottomMenuButton { - display: block; + display: inline-block; height: 100%; aspect-ratio: 1; } diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index 727fe08989..497ef72d04 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XTitlebar v-if="prefer.r.showTitlebar.value" style="flex-shrink: 0;"/> <div :class="$style.nonTitlebarArea"> - <XSidebar v-if="!isMobile" :class="$style.sidebar" :showWidgetButton="!isDesktop" @widgetButtonClick="widgetsShowing = true"/> + <XSidebar v-if="!isMobile" :class="$style.sidebar" :showWidgetButton="!showWidgetsSide" @widgetButtonClick="widgetsShowing = true"/> <div :class="[$style.contents, !isMobile && prefer.r.showTitlebar.value ? $style.withSidebarAndTitlebar : null]" @contextmenu.stop="onContextmenu"> <div> @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XMobileFooterMenu v-if="isMobile" ref="navFooter" v-model:drawerMenuShowing="drawerMenuShowing" v-model:widgetsShowing="widgetsShowing"/> </div> - <div v-if="isDesktop && !pageMetadata?.needWideArea" :class="$style.widgets"> + <div v-if="showWidgetsSide && !pageMetadata?.needWideArea" :class="$style.widgets"> <XWidgets/> </div> </div> @@ -64,7 +64,8 @@ const DESKTOP_THRESHOLD = 1100; const MOBILE_THRESHOLD = 500; // デスクトップでウィンドウを狭くしたときモバイルUIが表示されて欲しいことはあるので deviceKind === 'desktop' の判定は行わない -const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD); +const showWidgetsSide = window.innerWidth >= DESKTOP_THRESHOLD; + const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD); window.addEventListener('resize', () => { isMobile.value = deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD; @@ -102,14 +103,6 @@ if (window.innerWidth > 1024) { } } -onMounted(() => { - if (!isDesktop.value) { - window.addEventListener('resize', () => { - if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop.value = true; - }, { passive: true }); - } -}); - const onContextmenu = (ev) => { if (isLink(ev.target)) return; if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; diff --git a/packages/frontend/src/utility/storage.ts b/packages/frontend/src/utility/storage.ts new file mode 100644 index 0000000000..9df3a251e6 --- /dev/null +++ b/packages/frontend/src/utility/storage.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed, ref, shallowRef, watch, defineAsyncComponent } 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 async function enableStoragePersistence() { + try { + const persisted = await navigator.storage.persist(); + if (persisted) { + storagePersisted.value = true; + } else { + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + } + } catch (err) { + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + } +} + +export function skipStoragePersistence() { + store.set('showStoragePersistenceSuggestion', false); +} diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 0354c26d15..ac6c386995 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -29,14 +29,14 @@ ], "devDependencies": { "@types/js-yaml": "4.0.9", - "@types/node": "24.10.1", - "@typescript-eslint/eslint-plugin": "8.47.0", - "@typescript-eslint/parser": "8.47.0", - "chokidar": "4.0.3", - "esbuild": "0.27.0", - "execa": "9.6.0", + "@types/node": "24.10.2", + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", + "chokidar": "5.0.0", + "esbuild": "0.27.1", + "execa": "9.6.1", "nodemon": "3.1.11", - "tsx": "4.20.6", + "tsx": "4.21.0", "typescript": "5.9.3" }, "dependencies": { diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 8f94aab555..96a728da63 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -6193,6 +6193,18 @@ export interface Locale extends ILocale { * アニメーション画像を有効にする */ "enableAnimatedImages": string; + /** + * 設定の永続化 + */ + "settingsPersistence_title": string; + /** + * 設定の永続化を有効にすると、設定情報が失われるのを防止できます。 + */ + "settingsPersistence_description1": string; + /** + * 環境によっては有効化できない場合があります。 + */ + "settingsPersistence_description2": string; "_chat": { /** * 送信者の名前を表示 @@ -10936,6 +10948,36 @@ export interface Locale extends ILocale { * プロファイル情報のデバイス間同期を有効にする */ "enableSyncBetweenDevicesForProfiles": string; + /** + * UIの説明を見る + */ + "showHowToUse": string; + "_howToUse": { + /** + * カラム追加 + */ + "addColumn_title": string; + /** + * カラムの種類を選んで追加できます。 + */ + "addColumn_description": string; + /** + * UI設定 + */ + "settings_title": string; + /** + * デッキUIの詳細設定を行えます。 + */ + "settings_description": string; + /** + * プロファイル切り替え + */ + "switchProfile_title": string; + /** + * UIのレイアウトをプロファイルとして保存し、いつでも切り替えられるようにできます。 + */ + "switchProfile_description": string; + }; "_columns": { /** * メイン diff --git a/packages/icons-subsetter/package.json b/packages/icons-subsetter/package.json index 597520ff36..8d52555288 100644 --- a/packages/icons-subsetter/package.json +++ b/packages/icons-subsetter/package.json @@ -11,15 +11,15 @@ "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { - "@types/node": "24.10.1", + "@types/node": "24.10.2", "@types/wawoff2": "1.0.2", - "@typescript-eslint/eslint-plugin": "8.48.0", - "@typescript-eslint/parser": "8.48.0" + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0" }, "dependencies": { "@tabler/icons-webfont": "3.35.0", "harfbuzzjs": "0.4.13", - "tsx": "4.20.6", + "tsx": "4.21.0", "typescript": "5.9.3", "wawoff2": "2.0.1" }, diff --git a/packages/misskey-bubble-game/package.json b/packages/misskey-bubble-game/package.json index 978d77e0e4..3844740bf2 100644 --- a/packages/misskey-bubble-game/package.json +++ b/packages/misskey-bubble-game/package.json @@ -25,12 +25,12 @@ }, "devDependencies": { "@types/matter-js": "0.20.2", - "@types/node": "24.10.1", + "@types/node": "24.10.2", "@types/seedrandom": "3.0.8", - "@typescript-eslint/eslint-plugin": "8.48.0", - "@typescript-eslint/parser": "8.48.0", - "esbuild": "0.27.0", - "execa": "9.6.0", + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", + "esbuild": "0.27.1", + "execa": "9.6.1", "nodemon": "3.1.11", "typescript": "5.9.3" }, diff --git a/packages/misskey-js/generator/package.json b/packages/misskey-js/generator/package.json index 7c4a12552d..e9721911cc 100644 --- a/packages/misskey-js/generator/package.json +++ b/packages/misskey-js/generator/package.json @@ -8,13 +8,13 @@ }, "devDependencies": { "@readme/openapi-parser": "5.2.1", - "@types/node": "24.10.1", - "@typescript-eslint/eslint-plugin": "8.47.0", - "@typescript-eslint/parser": "8.47.0", + "@types/node": "24.10.2", + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", "openapi-types": "12.1.3", "openapi-typescript": "7.10.1", "ts-case-convert": "2.1.0", - "tsx": "4.20.6", + "tsx": "4.21.0", "typescript": "5.9.3", "eslint": "9.39.1" }, diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json index 395c2e2353..226428af13 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.1", + "version": "2025.12.2", "description": "Misskey SDK for JavaScript", "license": "MIT", "main": "./built/index.js", @@ -37,18 +37,18 @@ "directory": "packages/misskey-js" }, "devDependencies": { - "@microsoft/api-extractor": "7.55.1", - "@types/node": "24.10.1", - "@typescript-eslint/eslint-plugin": "8.47.0", - "@typescript-eslint/parser": "8.47.0", - "@vitest/coverage-v8": "4.0.13", - "esbuild": "0.27.0", - "execa": "9.6.0", + "@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", + "execa": "9.6.1", "ncp": "2.0.0", "nodemon": "3.1.11", "tsd": "0.33.0", "typescript": "5.9.3", - "vitest": "4.0.13", + "vitest": "4.0.15", "vitest-websocket-mock": "0.5.0" }, "files": [ diff --git a/packages/misskey-reversi/package.json b/packages/misskey-reversi/package.json index 85c829204e..e22ccd1e02 100644 --- a/packages/misskey-reversi/package.json +++ b/packages/misskey-reversi/package.json @@ -24,11 +24,11 @@ "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { - "@types/node": "24.10.1", - "@typescript-eslint/eslint-plugin": "8.48.0", - "@typescript-eslint/parser": "8.48.0", - "esbuild": "0.27.0", - "execa": "9.6.0", + "@types/node": "24.10.2", + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", + "esbuild": "0.27.1", + "execa": "9.6.1", "nodemon": "3.1.11", "typescript": "5.9.3" }, diff --git a/packages/sw/package.json b/packages/sw/package.json index b6b03adb54..1911524b8f 100644 --- a/packages/sw/package.json +++ b/packages/sw/package.json @@ -10,12 +10,12 @@ }, "dependencies": { "i18n": "workspace:*", - "esbuild": "0.27.0", + "esbuild": "0.27.1", "idb-keyval": "6.2.2", "misskey-js": "workspace:*" }, "devDependencies": { - "@typescript-eslint/parser": "8.48.0", + "@typescript-eslint/parser": "8.49.0", "@typescript/lib-webworker": "npm:@types/serviceworker@0.0.74", "eslint-plugin-import": "2.32.0", "nodemon": "3.1.11", |