Compare commits
59 Commits
frame-image
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d46089f9a | |||
| 78348007ed | |||
| 92f1e599db | |||
| 26b5979c76 | |||
| b1048525d2 | |||
| 4c31eb409c | |||
| f739cb6270 | |||
| 81bacb6203 | |||
| ee8dccea2f | |||
| 6d00645bc7 | |||
| baeed4bc80 | |||
| dba44daf9c | |||
| 46e6dd99d1 | |||
| f48af7f73b | |||
| 834e8b4c24 | |||
| 7ef0c96758 | |||
| b10074e939 | |||
| 260dbd150b | |||
| 79cbbcfe0f | |||
| c893f85864 | |||
| 24d4ffa2ec | |||
| 0b931daefd | |||
| cc05d93194 | |||
| 90345591bb | |||
| 730227f353 | |||
| 4acb37ee9d | |||
| 7025769c69 | |||
| 1a4ef8769f | |||
| 055cd0c250 | |||
| d35ddc77d2 | |||
| 8d871a58e3 | |||
| 99b0b436e0 | |||
| e3d5b95672 | |||
| 7420c10a58 | |||
| e40c84f31d | |||
| 994fc062cf | |||
| e7681f6c79 | |||
| 19053339d9 | |||
| b4e16c83e2 | |||
| 56cc89b521 | |||
| 1eab314b17 | |||
| ec21336d45 | |||
| e86e9b46b3 | |||
| 9b729b3d25 | |||
| 3c973e21f2 | |||
| 830e2f0a5b | |||
| 1620477a1c | |||
| 92b9a5218d | |||
| 9ed0d5ccec | |||
| a6d1727205 | |||
| 3c3982464f | |||
| bef73ff530 | |||
| 4d31c0b1de | |||
| a5f28c21e4 | |||
| c93ead7474 | |||
| 36880493cb | |||
| e8518de054 | |||
| b99e13e667 | |||
| 2518cf36d0 |
+47
-5
@@ -107,13 +107,51 @@ port: 3000
|
||||
|
||||
# Proxy trust settings
|
||||
#
|
||||
# Changes how the server interpret the origin IP of the request.
|
||||
# Specifies the IP addresses that Misskey will use as trusted
|
||||
# reverse proxies (e.g., nginx, Cloudflare). This affects how
|
||||
# Misskey determines the source IP for each request and is used
|
||||
# for important rate limiting and security features. If the value
|
||||
# is not set correctly, Misskey may use the IP address of the
|
||||
# reverse proxy instead of the actual source IP, which may lead to
|
||||
# unintended rate limiting or security vulnerabilities.
|
||||
# By default, the loopback network and private network address
|
||||
# ranges shown below are trusted.
|
||||
# If you are using a single reverse proxy and it is on the same
|
||||
# machine or the same private network as Misskey, it is unlikely you
|
||||
# need to change this setting, and the default setting is fine.
|
||||
# Also, if you are using multiple reverse proxy servers and they are
|
||||
# all on the same private network as Misskey, the default setting
|
||||
# is fine.
|
||||
# However, if you are using a reverse proxy server that accesses
|
||||
# Misskey web servers and streaming servers via public IP addresses
|
||||
# (for example, Cloudflare), you must set this variable.
|
||||
# When changing this setting, you can use one of the following values:
|
||||
#
|
||||
# Any format supported by Fastify is accepted.
|
||||
# Default: do not trust any proxies (i.e. trustProxy: false)
|
||||
# See: https://fastify.dev/docs/latest/reference/server/#trustproxy
|
||||
# - true: Trust all proxies
|
||||
# - false: Do not trust any proxies
|
||||
# - IP address, IP address range, or array of them: Trust hops that
|
||||
# match the specified criteria.
|
||||
# - Integer: Trust the nth hop from the front-facing proxy server as
|
||||
# the client.
|
||||
# For more information on how to configure this setting, please refer
|
||||
# to the Fastify documentation:
|
||||
# https://fastify.dev/docs/latest/Reference/Server/#trustproxy
|
||||
#
|
||||
# trustProxy: false
|
||||
# Note that if this variable is set, it overrides the default range,
|
||||
# so if you have both an external reverse proxy and a proxy on the
|
||||
# local host, you must include both IPs (or IP ranges).
|
||||
#
|
||||
#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'
|
||||
# # Example: If you are using some external reverse proxies like CDNs,
|
||||
# # you may need to add the CDN IP ranges here.
|
||||
# # If you're using Cloudflare, you can find IP Ranges at:
|
||||
# # https://www.cloudflare.com/ips/
|
||||
|
||||
# ┌──────────────────────────┐
|
||||
#───┘ PostgreSQL configuration └────────────────────────────────
|
||||
@@ -283,6 +321,10 @@ id: 'aidx'
|
||||
# Whether disable HSTS
|
||||
#disableHsts: true
|
||||
|
||||
# Enable internal IP-based rate limiting (default: true)
|
||||
# To configure them in reverse proxy instead, set this to false.
|
||||
#enableIpRateLimit: true
|
||||
|
||||
# Number of worker processes
|
||||
#clusterLimit: 1
|
||||
|
||||
|
||||
+21
-5
@@ -1,14 +1,30 @@
|
||||
## Unreleased
|
||||
## 2025.12.2
|
||||
|
||||
### Note
|
||||
v2025.12.0で行われた「configの`trustProxy`のデフォルト値を`false`に変更」について、正しく環境に応じた設定を行わないとサインインが困難になるといった状態を緩和するために、以下の対応を行いました。
|
||||
|
||||
**正しく設定しないと、上記のような不具合の原因となったり、セキュリティリスクが高まったりする可能性があります。必ず現在のconfigをご確認の上、必要に応じて値を変更してください。**
|
||||
|
||||
- `trustProxy`について、デフォルト(configに値が設定されていない状態)ではループバックアドレスとローカルIPアドレス空間を信頼するようにしました。
|
||||
- `trustProxy`の設定方法について、より詳細に記述しました。
|
||||
- リバースプロキシやCDNなどのより上流のレイヤでレートリミットを設定したい場合や、緊急時の一時的な緩和策として、Misskey内部でのIPアドレスペースでのレートリミットを無効化できるようにしました。
|
||||
|
||||
### General
|
||||
-
|
||||
- 依存関係の更新
|
||||
|
||||
### Client
|
||||
-
|
||||
- Enhance: デッキのUI説明を追加
|
||||
- Enhance: 設定がブラウザによって消去されないようにするオプションを追加
|
||||
- Fix: バージョン表記のないPlayが正しく動作しない問題を修正
|
||||
バージョン表記のないものは v0.x 系として実行されます。v1.x 系で動作させたい場合は必ずバージョン表記を含めてください。
|
||||
- Fix: デッキUIでメニュー位置を下にしているとプロファイル削除ボタンが表示されないのを修正
|
||||
- Fix: 一部のUnicode絵文字のリアクションがボタンにならない問題を修正
|
||||
|
||||
### Server
|
||||
-
|
||||
|
||||
- Enhance: Misskey内部でのIPアドレスペースでのレートリミットを無効化できるように
|
||||
- リバースプロキシやCDNなど別のレイヤで別途レートリミットを設定する場合や、ローカルでのテスト用途等として利用することを想定しています。
|
||||
- デフォルトは `enableIpRateLimit: true`(Misskey内部でのIPアドレスペースでのレートリミットは有効)です。
|
||||
- Fix: コントロールパネルのジョブキューページで使用される一部APIの応答速度を改善
|
||||
|
||||
## 2025.12.1
|
||||
|
||||
|
||||
@@ -1557,6 +1557,9 @@ _settings:
|
||||
showPageTabBarBottom: "ページのタブバーを下部に表示"
|
||||
emojiPaletteBanner: "絵文字ピッカーに固定表示するプリセットをパレットとして登録したり、ピッカーの表示方法をカスタマイズしたりできます。"
|
||||
enableAnimatedImages: "アニメーション画像を有効にする"
|
||||
settingsPersistence_title: "設定の永続化"
|
||||
settingsPersistence_description1: "設定の永続化を有効にすると、設定情報が失われるのを防止できます。"
|
||||
settingsPersistence_description2: "環境によっては有効化できない場合があります。"
|
||||
|
||||
_chat:
|
||||
showSenderName: "送信者の名前を表示"
|
||||
@@ -2890,6 +2893,15 @@ _deck:
|
||||
usedAsMinWidthWhenFlexible: "「幅を自動調整」が有効の場合、これが幅の最小値となります"
|
||||
flexible: "幅を自動調整"
|
||||
enableSyncBetweenDevicesForProfiles: "プロファイル情報のデバイス間同期を有効にする"
|
||||
showHowToUse: "UIの説明を見る"
|
||||
|
||||
_howToUse:
|
||||
addColumn_title: "カラム追加"
|
||||
addColumn_description: "カラムの種類を選んで追加できます。"
|
||||
settings_title: "UI設定"
|
||||
settings_description: "デッキUIの詳細設定を行えます。"
|
||||
switchProfile_title: "プロファイル切り替え"
|
||||
switchProfile_description: "UIのレイアウトをプロファイルとして保存し、いつでも切り替えられるようにできます。"
|
||||
|
||||
_columns:
|
||||
main: "メイン"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2025.12.1",
|
||||
"version": "2025.12.2",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -71,11 +71,11 @@
|
||||
"utf-8-validate": "6.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.947.0",
|
||||
"@aws-sdk/lib-storage": "3.947.0",
|
||||
"@aws-sdk/client-s3": "3.948.0",
|
||||
"@aws-sdk/lib-storage": "3.948.0",
|
||||
"@discordapp/twemoji": "16.0.1",
|
||||
"@fastify/accepts": "5.0.4",
|
||||
"@fastify/cors": "11.1.0",
|
||||
"@fastify/cors": "11.2.0",
|
||||
"@fastify/express": "4.0.2",
|
||||
"@fastify/http-proxy": "11.4.1",
|
||||
"@fastify/multipart": "9.3.0",
|
||||
@@ -166,7 +166,7 @@
|
||||
"slacc": "0.0.10",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"systeminformation": "5.27.12",
|
||||
"systeminformation": "5.27.14",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.5",
|
||||
"tsc-alias": "1.8.16",
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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+/);
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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)`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"misskey-js": "workspace:*",
|
||||
"punycode.js": "2.3.1",
|
||||
"rollup": "4.53.3",
|
||||
"sass": "1.95.0",
|
||||
"sass": "1.95.1",
|
||||
"shiki": "3.19.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"uuid": "13.0.0",
|
||||
@@ -61,8 +61,8 @@
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vue-component-type-helpers": "3.1.7",
|
||||
"vue-component-type-helpers": "3.1.8",
|
||||
"vue-eslint-parser": "10.2.0",
|
||||
"vue-tsc": "3.1.7"
|
||||
"vue-tsc": "3.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"is-file-animated": "1.0.2",
|
||||
"json5": "2.2.3",
|
||||
"matter-js": "0.20.0",
|
||||
"mediabunny": "1.25.7",
|
||||
"mediabunny": "1.25.8",
|
||||
"mfm-js": "0.25.0",
|
||||
"misskey-bubble-game": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
@@ -66,7 +66,7 @@
|
||||
"qr-scanner": "1.4.2",
|
||||
"rollup": "4.53.3",
|
||||
"sanitize-html": "2.17.0",
|
||||
"sass": "1.95.0",
|
||||
"sass": "1.95.1",
|
||||
"shiki": "3.19.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.181.2",
|
||||
@@ -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.4",
|
||||
"@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,13 +90,13 @@
|
||||
"@storybook/core-events": "8.6.14",
|
||||
"@storybook/manager-api": "8.6.14",
|
||||
"@storybook/preview-api": "8.6.14",
|
||||
"@storybook/react": "10.1.4",
|
||||
"@storybook/react-vite": "10.1.4",
|
||||
"@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.4",
|
||||
"@storybook/vue3-vite": "10.1.4",
|
||||
"@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",
|
||||
@@ -133,7 +133,7 @@
|
||||
"react-dom": "19.2.1",
|
||||
"seedrandom": "3.0.5",
|
||||
"start-server-and-test": "2.1.3",
|
||||
"storybook": "10.1.4",
|
||||
"storybook": "10.1.5",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
@@ -141,7 +141,7 @@
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "4.0.15",
|
||||
"vitest-fetch-mock": "0.4.5",
|
||||
"vue-component-type-helpers": "3.1.7",
|
||||
"vue-component-type-helpers": "3.1.8",
|
||||
"vue-eslint-parser": "10.2.0",
|
||||
"vue-tsc": "3.1.8"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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の必要なし
|
||||
|
||||
@@ -118,6 +118,10 @@ export const store = markRaw(new Pizzax('base', {
|
||||
where: 'device',
|
||||
default: true,
|
||||
},
|
||||
showStoragePersistenceSuggestion: {
|
||||
where: 'device',
|
||||
default: true,
|
||||
},
|
||||
|
||||
//#region TODO: そのうち消す (preferに移行済み)
|
||||
defaultWithReplies: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export const TIPS = [
|
||||
'clips',
|
||||
'userLists',
|
||||
'postForm',
|
||||
'deck',
|
||||
'tl.home',
|
||||
'tl.local',
|
||||
'tl.social',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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": {
|
||||
/**
|
||||
* メイン
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+294
-314
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -35,5 +35,4 @@ ignorePatchFailures: false
|
||||
minimumReleaseAge: 10080 # delay 7days to mitigate supply-chain attack
|
||||
minimumReleaseAgeExclude:
|
||||
- '@syuilo/aiscript'
|
||||
- vue-tsc
|
||||
- '@vue/language-core'
|
||||
- systeminformation # 脆弱性対応。そのうち消すこと
|
||||
|
||||
Reference in New Issue
Block a user