Compare commits

..
Author SHA1 Message Date
kakkokari-gtyih 368c40c653 Merge remote-tracking branch 'msky/develop' into copilot/add-user-mute-settings 2025-12-16 22:09:06 +09:00
かっこかり 68d8445fe7 Update CHANGELOG.md 2025-12-02 16:52:45 +09:00
kakkokari-gtyih 6d80f9ef2f Merge branch 'develop' into copilot/add-user-mute-settings 2025-12-02 14:19:48 +09:00
Sayamame-beans 4a16d7f354 Merge branch 'develop' into copilot/add-user-mute-settings 2025-11-27 22:58:26 +09:00
kakkokari-gtyih cdaf57d4c2 Update Changelog 2025-11-24 00:50:07 +09:00
kakkokari-gtyih 9fe09c041f Merge branch 'develop' into copilot/add-user-mute-settings 2025-11-24 00:49:31 +09:00
かっこかり 6ef38a4cf9 Merge branch 'develop' into copilot/add-user-mute-settings 2025-11-10 18:38:04 +09:00
かっこかり 855a652439 enhance(frontend): ミュート・ロール付与期間を任意の長さに設定できるように (#16766)
* enhance(frontend): ミュートの長さを自由に設定できるように

* enhance(frontend): ロールアサインの長さを自由に設定できるように

* Update Changelog
2025-11-10 15:45:57 +09:00
copilot-swe-agent[bot]andkakkokari-gtyih 50bbc71098 Apply code review suggestions - use explicit null checks
Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
2025-11-09 15:03:53 +00:00
かっこかり 4762dfd5c7 Merge branch 'develop' into copilot/add-user-mute-settings 2025-11-09 13:05:32 +09:00
kakkokari-gtyih 3e6150174a Update Changelog 2025-11-08 15:19:56 +09:00
kakkokari-gtyih 6e2360e9b1 fix 2025-11-08 13:39:12 +09:00
かっこかり 7d1da29c48 enhance(frontend): implement mute setting dialog for enhanced mute (#16763)
* enhance(frontend): implement mute setting dialog for enhanced mute

* remove unnecessary defs

* fix description

* fix lint
2025-11-08 13:11:52 +09:00
kakkokari-gtyih 2b75b575ac build misskey-js with types 2025-11-08 11:56:42 +09:00
copilot-swe-agent[bot]andsyuilo 8d1b2f2089 Fix code review issues - update comments to English
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-11-07 12:11:46 +00:00
copilot-swe-agent[bot]andsyuilo 18ba94c909 Add test for timeline-only muting feature
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-11-07 12:09:26 +00:00
copilot-swe-agent[bot]andsyuilo 685847e1b6 Add mutingType field to Muting model and update related code
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-11-07 12:04:42 +00:00
copilot-swe-agent[bot] 1810d6e837 Initial plan 2025-11-07 11:52:31 +00:00
64 changed files with 1145 additions and 768 deletions
+15 -47
View File
@@ -107,51 +107,23 @@ port: 3000
# Proxy trust settings
#
# 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:
# Changes how the server interpret the origin IP of the request.
#
# - 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
# Any format supported by Fastify is accepted.
# Default: trust all proxies (i.e. trustProxy: true)
# See: https://fastify.dev/docs/latest/reference/server/#trustproxy
# To improve security, we recommend that you configure your settings appropriately.
# Incorrect configuration can cause issues such as difficulty signing in,
# so please configure your settings carefully.
#
# 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/
#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'
#]
# ┌──────────────────────────┐
#───┘ PostgreSQL configuration └────────────────────────────────
@@ -321,10 +293,6 @@ 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
+5 -17
View File
@@ -1,30 +1,18 @@
## 2025.12.2
### Note
v2025.12.0で行われた「configの`trustProxy`のデフォルト値を`false`に変更」について、正しく環境に応じた設定を行わないとサインインが困難になるといった状態を緩和するために、以下の対応を行いました。
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絵文字のリアクションがボタンにならない問題を修正
- Enhance: ミュートの付与期間を自由に設定できるように
- Enhance: ロールの付与期間を自由に設定できるように
- Fix: バージョン表記のないPlayが正しく動作しない問題を修正
### Server
- Enhance: Misskey内部でのIPアドレスペースでのレートリミットを無効化できるように
- リバースプロキシやCDNなど別のレイヤで別途レートリミットを設定する場合や、ローカルでのテスト用途等として利用することを想定しています。
- デフォルトは `enableIpRateLimit: true`(Misskey内部でのIPアドレスペースでのレートリミットは有効)です。
- Fix: コントロールパネルのジョブキューページで使用される一部APIの応答速度を改善
## 2025.12.1
+5 -12
View File
@@ -146,6 +146,9 @@ markAsSensitive: "センシティブとして設定"
unmarkAsSensitive: "センシティブを解除する"
enterFileName: "ファイル名を入力"
mute: "ミュート"
muteType: "ミュートする範囲"
muteTypeDescription: "ミュートを適用する範囲を設定できます。「タイムラインのみ」に設定すると、タイムラインや検索結果上からは見えなくなりますが、通知は受け取ります。"
muteTypeTimeline: "タイムラインのみ"
unmute: "ミュート解除"
renoteMute: "リノートをミュート"
renoteUnmute: "リノートのミュートを解除"
@@ -956,6 +959,7 @@ instanceDefaultLightTheme: "サーバーデフォルトのライトテーマ"
instanceDefaultDarkTheme: "サーバーデフォルトのダークテーマ"
instanceDefaultThemeDescription: "オブジェクト形式のテーマコードを記入します。"
mutePeriod: "ミュートする期限"
mutePeriodDescription: "期限はあくまで目安です。反映が数分遅れる場合があります。"
period: "期限"
indefinitely: "無期限"
tenMinutes: "10分"
@@ -1406,6 +1410,7 @@ youAreAdmin: "あなたは管理者です"
frame: "フレーム"
presets: "プリセット"
zeroPadding: "ゼロ埋め"
muteConfirm: "ミュートしますか?"
_imageEditing:
_vars:
@@ -1557,9 +1562,6 @@ _settings:
showPageTabBarBottom: "ページのタブバーを下部に表示"
emojiPaletteBanner: "絵文字ピッカーに固定表示するプリセットをパレットとして登録したり、ピッカーの表示方法をカスタマイズしたりできます。"
enableAnimatedImages: "アニメーション画像を有効にする"
settingsPersistence_title: "設定の永続化"
settingsPersistence_description1: "設定の永続化を有効にすると、設定情報が失われるのを防止できます。"
settingsPersistence_description2: "環境によっては有効化できない場合があります。"
_chat:
showSenderName: "送信者の名前を表示"
@@ -2893,15 +2895,6 @@ _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
View File
@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2025.12.2",
"version": "2025.12.2-beta.0",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddMutingType1762516776421 {
name = 'AddMutingType1762516776421'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "muting" ADD "mutingType" varchar(128) NOT NULL DEFAULT 'all'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "muting" DROP COLUMN "mutingType"`);
}
}
+5 -5
View File
@@ -71,11 +71,11 @@
"utf-8-validate": "6.0.5"
},
"dependencies": {
"@aws-sdk/client-s3": "3.948.0",
"@aws-sdk/lib-storage": "3.948.0",
"@aws-sdk/client-s3": "3.947.0",
"@aws-sdk/lib-storage": "3.947.0",
"@discordapp/twemoji": "16.0.1",
"@fastify/accepts": "5.0.4",
"@fastify/cors": "11.2.0",
"@fastify/cors": "11.1.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.14",
"systeminformation": "5.27.12",
"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.8",
"@types/simple-oauth2": "5.0.7",
"@types/sinonjs__fake-timers": "15.0.1",
"@types/supertest": "6.0.3",
"@types/tinycolor2": "1.4.6",
+2 -12
View File
@@ -30,7 +30,6 @@ type Source = {
socket?: string;
trustProxy?: FastifyServerOptions['trustProxy'];
chmodSocket?: string;
enableIpRateLimit?: boolean;
disableHsts?: boolean;
db: {
host: string;
@@ -121,9 +120,8 @@ export type Config = {
url: string;
port: number;
socket: string | undefined;
trustProxy: NonNullable<FastifyServerOptions['trustProxy']>;
trustProxy: FastifyServerOptions['trustProxy'];
chmodSocket: string | undefined;
enableIpRateLimit: boolean;
disableHsts: boolean | undefined;
db: {
host: string;
@@ -265,17 +263,9 @@ export function loadConfig(): Config {
url: url.origin,
port: config.port ?? parseInt(process.env.PORT ?? '', 10),
socket: config.socket,
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',
],
trustProxy: config.trustProxy,
chmodSocket: config.chmodSocket,
disableHsts: config.disableHsts,
enableIpRateLimit: config.enableIpRateLimit ?? true,
host,
hostname,
scheme,
+1 -1
View File
@@ -7,6 +7,7 @@ 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';
@@ -83,7 +84,6 @@ export class AiService {
@bindThis
private async getCpuFlags(): Promise<string[]> {
const si = await import('systeminformation');
const str = await si.cpuFlags();
return str.split(/\s+/);
}
+6 -6
View File
@@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js';
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing, MiMuting } from '@/models/_.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
@@ -21,7 +21,7 @@ export class CacheService implements OnApplicationShutdown {
public localUserByIdCache: MemoryKVCache<MiLocalUser>;
public uriPersonCache: MemoryKVCache<MiUser | null>;
public userProfileCache: RedisKVCache<MiUserProfile>;
public userMutingsCache: RedisKVCache<Set<string>>;
public userMutingsCache: RedisKVCache<Map<string, Pick<MiMuting, 'mutingType'>>>;
public userBlockingCache: RedisKVCache<Set<string>>;
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
public renoteMutingsCache: RedisKVCache<Set<string>>;
@@ -69,12 +69,12 @@ export class CacheService implements OnApplicationShutdown {
fromRedisConverter: (value) => JSON.parse(value), // TODO: date型の考慮
});
this.userMutingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userMutings', {
this.userMutingsCache = new RedisKVCache<Map<string, Pick<MiMuting, 'mutingType'>>>(this.redisClient, 'userMutings', {
lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))),
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
fromRedisConverter: (value) => new Set(JSON.parse(value)),
fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId', 'mutingType'] }).then(xs => new Map(xs.map(x => [x.muteeId, { mutingType: x.mutingType }]))),
toRedisConverter: (value) => JSON.stringify(Array.from(value.entries())),
fromRedisConverter: (value) => new Map(JSON.parse(value)),
});
this.userBlockingCache = new RedisKVCache<Set<string>>(this.redisClient, 'userBlocking', {
@@ -111,7 +111,7 @@ export class FanoutTimelineEndpointService {
if (ps.me) {
const me = ps.me;
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingMap,
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
userMutedInstances,
@@ -124,6 +124,8 @@ export class FanoutTimelineEndpointService {
this.channelMutingService.mutingChannelsCache.fetch(me.id),
]);
const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
const parentFilter = filter;
filter = (note) => {
if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false;
@@ -108,7 +108,8 @@ export class NotificationService implements OnApplicationShutdown {
}
const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId);
if (mutings.has(notifierId)) {
const muting = mutings.get(notifierId);
if (muting != null && muting.mutingType === 'all') {
return null;
}
+4 -2
View File
@@ -286,14 +286,16 @@ export class SearchService {
}
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingMap,
userIdsWhoBlockingMe,
] = me
? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
])
: [new Set<string>(), new Set<string>()];
: [new Map<string, { mutingType: 'all' | 'timelineOnly' }>(), new Set<string>()];
const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
const query = this.notesRepository.createQueryBuilder('note')
.innerJoinAndSelect('note.user', 'user')
@@ -24,12 +24,13 @@ export class UserMutingService {
}
@bindThis
public async mute(user: MiUser, target: MiUser, expiresAt: Date | null = null): Promise<void> {
public async mute(user: MiUser, target: MiUser, expiresAt: Date | null = null, mutingType: 'all' | 'timelineOnly' = 'all'): Promise<void> {
await this.mutingsRepository.insert({
id: this.idService.gen(),
expiresAt: expiresAt ?? null,
muterId: user.id,
muteeId: target.id,
mutingType: mutingType,
});
this.cacheService.userMutingsCache.refresh(user.id);
@@ -40,6 +40,7 @@ export class MutingEntityService {
id: muting.id,
createdAt: this.idService.parse(muting.id).date.toISOString(),
expiresAt: muting.expiresAt ? muting.expiresAt.toISOString() : null,
mutingType: muting.mutingType,
muteeId: muting.muteeId,
mutee: hints?.packedMutee ?? this.userEntityService.pack(muting.muteeId, me, {
schema: 'UserDetailedNotMe',
@@ -289,12 +289,13 @@ export class NotificationEntityService implements OnModuleInit {
*/
#validateNotifier <T extends MiNotification | MiGroupedNotification> (
notification: T,
userIdsWhoMeMuting: Set<MiUser['id']>,
userIdsWhoMeMutingMap: Map<MiUser['id'], { mutingType: 'all' | 'timelineOnly' }>,
userMutedInstances: Set<string>,
notifiers: MiUser[],
): boolean {
if (!('notifierId' in notification)) return true;
if (userIdsWhoMeMuting.has(notification.notifierId)) return false;
const muting = userIdsWhoMeMutingMap.get(notification.notifierId);
if (muting != null && muting.mutingType === 'all') return false;
const notifier = notifiers.find(x => x.id === notification.notifierId) ?? null;
@@ -324,7 +325,7 @@ export class NotificationEntityService implements OnModuleInit {
meId: MiUser['id'],
): Promise<T[]> {
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingMap,
userMutedInstances,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(meId),
@@ -337,7 +338,7 @@ export class NotificationEntityService implements OnModuleInit {
}) : [];
const filteredNotifications = ((await Promise.all(notifications.map(async (notification) => {
const isValid = this.#validateNotifier(notification, userIdsWhoMeMuting, userMutedInstances, notifiers);
const isValid = this.#validateNotifier(notification, userIdsWhoMeMutingMap, userMutedInstances, notifiers);
return isValid ? notification : null;
}))) as [T | null] ).filter(x => x != null);
@@ -4,12 +4,13 @@
*/
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();
@@ -96,14 +97,12 @@ 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];
@@ -111,6 +110,5 @@ 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,11 +4,15 @@
*/
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}`);
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)`);
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)`);
}
+6
View File
@@ -19,6 +19,12 @@ export class MiMuting {
})
public expiresAt: Date | null;
@Column('varchar', {
length: 128,
default: 'all',
})
public mutingType: 'all' | 'timelineOnly';
@Index()
@Column({
...id(),
@@ -22,6 +22,11 @@ export const packedMutingSchema = {
optional: false, nullable: true,
format: 'date-time',
},
mutingType: {
type: 'string',
optional: false, nullable: false,
enum: ['all', 'timelineOnly'],
},
muteeId: {
type: 'string',
optional: false, nullable: false,
+1 -1
View File
@@ -75,7 +75,7 @@ export class ServerService implements OnApplicationShutdown {
@bindThis
public async launch(): Promise<void> {
const fastify = Fastify({
trustProxy: this.config.trustProxy,
trustProxy: this.config.trustProxy ?? true,
logger: false,
});
this.#fastify = fastify;
@@ -313,15 +313,16 @@ export class ApiCallService implements OnApplicationShutdown {
}
if (ep.meta.limit) {
let limitActor: string | null = null;
let limitActor: string | null;
if (user) {
limitActor = user.id;
} 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.');
} else {
if (request.ip === '::1' || request.ip === '127.0.0.1') {
console.warn('request ip is localhost, maybe caused by misconfiguration of trustProxy or reverse proxy');
limitActor = null;
} else {
limitActor = getIpHash(request.ip);
}
limitActor = getIpHash(request.ip);
}
const limit = Object.assign({}, ep.meta.limit);
@@ -15,7 +15,6 @@ 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';
@@ -24,7 +23,6 @@ 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';
@@ -33,8 +31,6 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
@Injectable()
export class SigninApiService {
private logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
@@ -54,7 +50,6 @@ export class SigninApiService {
@Inject(DI.signinsRepository)
private signinsRepository: SigninsRepository,
private loggerService: LoggerService,
private idService: IdService,
private rateLimiterService: RateLimiterService,
private signinService: SigninService,
@@ -62,7 +57,6 @@ export class SigninApiService {
private webAuthnService: WebAuthnService,
private captchaService: CaptchaService,
) {
this.logger = this.loggerService.getLogger('Signin');
}
@bindThis
@@ -95,11 +89,10 @@ export class SigninApiService {
return { error };
}
if (request.ip === '::1' || request.ip === '127.0.0.1') {
console.warn('request ip is localhost, maybe caused by misconfiguration of trustProxy or reverse proxy');
} else {
// not more than 1 attempt per second and not more than 10 attempts per hour
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);
@@ -84,11 +84,9 @@ export class SigninWithPasskeyApiService {
return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
};
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.');
}
if (request.ip === '::1' || request.ip === '127.0.0.1') {
console.warn('request ip is localhost, maybe caused by misconfiguration of trustProxy or reverse proxy');
} else {
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
@@ -52,14 +52,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
const jobs = await this.deliverQueue.getJobs(['delayed']);
const counts = new Map<string, number>();
const res = [] as [string, number][];
for (const job of jobs) {
const host = new URL(job.data.to).host;
counts.set(host, (counts.get(host) ?? 0) + 1);
if (res.find(x => x[0] === host)) {
res.find(x => x[0] === host)![1]++;
} else {
res.push([host, 1]);
}
}
const res = [...counts.entries()].sort((a, b) => b[1] - a[1]);
res.sort((a, b) => b[1] - a[1]);
return res;
});
@@ -52,14 +52,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
const jobs = await this.inboxQueue.getJobs(['delayed']);
const counts = new Map<string, number>();
const res = [] as [string, number][];
for (const job of jobs) {
const host = new URL(job.data.signature.keyId).host;
counts.set(host, (counts.get(host) ?? 0) + 1);
if (res.find(x => x[0] === host)) {
res.find(x => x[0] === host)![1]++;
} else {
res.push([host, 1]);
}
}
const res = [...counts.entries()].sort((a, b) => b[1] - a[1]);
res.sort((a, b) => b[1] - a[1]);
return res;
});
@@ -4,6 +4,7 @@
*/
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';
@@ -111,8 +112,6 @@ 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();
@@ -55,6 +55,12 @@ export const paramDef = {
nullable: true,
description: 'A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute.',
},
mutingType: {
type: 'string',
enum: ['all', 'timelineOnly'],
default: 'all',
description: 'Type of muting. `all` mutes everything including notifications. `timelineOnly` mutes only timeline and search, but allows notifications.',
},
},
required: ['userId'],
} as const;
@@ -98,7 +104,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return;
}
await this.userMutingService.mute(muter, mutee, ps.expiresAt ? new Date(ps.expiresAt) : null);
await this.userMutingService.mute(muter, mutee, ps.expiresAt ? new Date(ps.expiresAt) : null, ps.mutingType ?? 'all');
});
}
}
@@ -80,12 +80,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingMap,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>()];
]) : [new Map<string, { mutingType: 'all' | 'timelineOnly' }>(), new Set<string>()];
const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
@@ -4,6 +4,7 @@
*/
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';
@@ -92,8 +93,6 @@ 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();
@@ -73,10 +73,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingMap,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id),
]) : [new Set<string>()];
]) : [new Map<string, { mutingType: 'all' | 'timelineOnly' }>()];
// Convert to Set for backward compatibility with isUserRelated
const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
@@ -94,7 +94,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}
const userIdsWhoMeMuting = me ? await this.cacheService.userMutingsCache.fetch(me.id) : new Set<string>();
const userIdsWhoMeMutingMap = me ? await this.cacheService.userMutingsCache.fetch(me.id) : new Map<string, { mutingType: 'all' | 'timelineOnly' }>();
const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
@@ -38,6 +38,7 @@ export default class Connection {
public followingChannels: Set<string> = new Set();
public mutingChannels: Set<string> = new Set();
public userIdsWhoMeMuting: Set<string> = new Set();
public userIdsWhoMeMutingMap: Map<string, { mutingType: 'all' | 'timelineOnly' }> = new Map();
public userIdsWhoBlockingMe: Set<string> = new Set();
public userIdsWhoMeMutingRenotes: Set<string> = new Set();
public userMutedInstances: Set<string> = new Set();
@@ -64,7 +65,7 @@ export default class Connection {
following,
followingChannels,
mutingChannels,
userIdsWhoMeMuting,
userIdsWhoMeMutingMap,
userIdsWhoBlockingMe,
userIdsWhoMeMutingRenotes,
] = await Promise.all([
@@ -80,7 +81,8 @@ export default class Connection {
this.following = following;
this.followingChannels = followingChannels;
this.mutingChannels = mutingChannels;
this.userIdsWhoMeMuting = userIdsWhoMeMuting;
this.userIdsWhoMeMutingMap = userIdsWhoMeMutingMap;
this.userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
this.userIdsWhoBlockingMe = userIdsWhoBlockingMe;
this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes;
this.userMutedInstances = new Set(userProfile.mutedInstances);
@@ -0,0 +1,130 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { api, post, react, signup, waitFire } from '../utils.js';
import type * as misskey from 'misskey-js';
describe('Timeline-only Mute', () => {
// alice timeline-only mutes carol
let alice: misskey.entities.SignupResponse;
let bob: misskey.entities.SignupResponse;
let carol: misskey.entities.SignupResponse;
beforeAll(async () => {
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' });
// Timeline-only mute: alice ==> carol
await api('mute/create', {
userId: carol.id,
mutingType: 'timelineOnly',
}, alice);
}, 1000 * 60 * 2);
test('タイムラインオンリーミュート作成', async () => {
const res = await api('mute/create', {
userId: bob.id,
mutingType: 'timelineOnly',
}, alice);
assert.strictEqual(res.status, 204);
// Clean up side effects so tests can run independently
await api('mute/delete', {
userId: bob.id,
}, alice);
});
describe('Timeline', () => {
test('タイムラインにtimelineOnlyミュートしているユーザーの投稿が含まれない', async () => {
const aliceNote = await post(alice, { text: 'hi' });
const bobNote = await post(bob, { text: 'hi' });
const carolNote = await post(carol, { text: 'hi' });
const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
});
});
describe('Notification', () => {
test('timelineOnlyミュートしているユーザーからの通知は届く(リアクション)', async () => {
const aliceNote = await post(alice, { text: 'hi' });
await react(bob, aliceNote, 'like');
await react(carol, aliceNote, 'like');
const res = await api('i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
// carol is timelineOnly muted, so notifications should still come through
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), true);
});
test('timelineOnlyミュートしているユーザーからのリプライ通知は届く', async () => {
const aliceNote = await post(alice, { text: 'hi' });
await post(bob, { text: '@alice hi', replyId: aliceNote.id });
await post(carol, { text: '@alice hi', replyId: aliceNote.id });
const res = await api('i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
// carol is timelineOnly muted, so reply notifications should still come through
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), true);
});
test('timelineOnlyミュートしているユーザーからのメンション通知は届く', async () => {
await post(alice, { text: 'hi' });
await post(bob, { text: '@alice hi' });
await post(carol, { text: '@alice hi' });
const res = await api('i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
// carol is timelineOnly muted, so mention notifications should still come through
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), true);
});
test('timelineOnlyミュートしているユーザーからの引用リノート通知は届く', async () => {
const aliceNote = await post(alice, { text: 'hi' });
await post(bob, { text: 'hi', renoteId: aliceNote.id });
await post(carol, { text: 'hi', renoteId: aliceNote.id });
const res = await api('i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true);
// carol is timelineOnly muted, so quote notifications should still come through
assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), true);
});
test('timelineOnlyミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてくる', async () => {
// Reset state
await api('notifications/mark-all-as-read', {}, alice);
const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadNotification');
// carol is timelineOnly muted, so notification stream should fire
assert.strictEqual(fired, true);
});
});
});
+3 -3
View File
@@ -26,7 +26,7 @@
"misskey-js": "workspace:*",
"punycode.js": "2.3.1",
"rollup": "4.53.3",
"sass": "1.95.1",
"sass": "1.95.0",
"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.8",
"vue-component-type-helpers": "3.1.7",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "3.1.8"
"vue-tsc": "3.1.7"
}
}
+4 -21
View File
@@ -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(forceColorizeEmoji(char))
return unicodeEmojisMap.get(colorizeEmoji(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(forceColorizeEmoji(char)) || unicodeEmojisMap.has(char);
return unicodeEmojisMap.has(colorizeEmoji(char)) || unicodeEmojisMap.has(char);
}
export function getEmojiName(char: string): string {
// Colorize it because emojilist.json assumes that
const idx = _indexByChar.get(forceColorizeEmoji(char)) ?? _indexByChar.get(char);
const idx = _indexByChar.get(colorizeEmoji(char)) ?? _indexByChar.get(char);
if (idx === undefined) {
// 絵文字情報がjsonに無い場合は名前の取得が出来ないのでそのまま返すしか無い
return char;
@@ -72,24 +72,7 @@ export function getEmojiName(char: string): string {
* テキストスタイル絵文字(U+260Eなどの1文字で表現される絵文字)をカラースタイル絵文字に変換します(VS16:U+FE0Fを付与)。
*/
export function colorizeEmoji(char: string) {
// <文字列>.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('');
}
return char.length === 1 ? `${char}\uFE0F` : char;
}
export interface CustomEmojiFolderTree {
+9 -9
View File
@@ -55,7 +55,7 @@
"is-file-animated": "1.0.2",
"json5": "2.2.3",
"matter-js": "0.20.0",
"mediabunny": "1.25.8",
"mediabunny": "1.25.7",
"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.1",
"sass": "1.95.0",
"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.5",
"@storybook/addon-links": "10.1.4",
"@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.5",
"@storybook/react-vite": "10.1.5",
"@storybook/react": "10.1.4",
"@storybook/react-vite": "10.1.4",
"@storybook/test": "8.6.14",
"@storybook/theming": "8.6.14",
"@storybook/types": "8.6.14",
"@storybook/vue3": "10.1.5",
"@storybook/vue3-vite": "10.1.5",
"@storybook/vue3": "10.1.4",
"@storybook/vue3-vite": "10.1.4",
"@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.5",
"storybook": "10.1.4",
"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.8",
"vue-component-type-helpers": "3.1.7",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "3.1.8"
}
@@ -0,0 +1,217 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')">
<div :class="$style.root" class="_gaps_m">
<div class="_gaps_s">
<div :class="$style.header">
<div :class="$style.icon">
<i class="ti ti-alert-triangle"></i>
</div>
<div :class="$style.title">{{ i18n.ts.muteConfirm }}</div>
</div>
</div>
<div class="_gaps">
<FormSlot>
<div class="_gaps_s">
<MkSelect v-model="periodModel" :items="periodDef">
<template #label>{{ i18n.ts.mutePeriod }}</template>
</MkSelect>
<MkInput
v-if="periodModel === 'custom'"
v-model="manualExpiresAt"
type="datetime-local"
></MkInput>
</div>
<template #caption>{{ i18n.ts.mutePeriodDescription }}</template>
</FormSlot>
<MkSelect v-if="withMuteType" v-model="muteTypeModel" :items="muteTypeDef">
<template #label>{{ i18n.ts.muteType }}</template>
<template #caption>{{ i18n.ts.muteTypeDescription }}</template>
</MkSelect>
</div>
<div :class="$style.buttons">
<MkButton inline rounded @click="cancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton inline primary rounded :disabled="!canSave" @click="ok">{{ i18n.ts.ok }}</MkButton>
</div>
</div>
</MkModal>
</template>
<script lang="ts">
import { i18n } from '@/i18n.js';
import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue';
import MkInput from './MkInput.vue';
const periodItems = [{
value: 'indefinitely', label: i18n.ts.indefinitely,
}, {
value: 'tenMinutes', label: i18n.ts.tenMinutes,
}, {
value: 'oneHour', label: i18n.ts.oneHour,
}, {
value: 'oneDay', label: i18n.ts.oneDay,
}, {
value: 'oneWeek', label: i18n.ts.oneWeek,
}, {
value: 'custom', label: i18n.ts.custom,
}] as const satisfies MkSelectItem[];
const muteTypeItems = [{
value: 'all', label: i18n.ts.all,
}, {
value: 'timelineOnly', label: i18n.ts.muteTypeTimeline,
}] as const satisfies MkSelectItem[];
export type MkMuteSettingDialogDoneEvent = { canceled: true } | { canceled: false, expiresAt: number | null, type: GetMkSelectValueTypesFromDef<typeof muteTypeItems> };
</script>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, useTemplateRef, ref, computed } from 'vue';
import FormSlot from '@/components/form/slot.vue';
import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue';
import MkSelect from '@/components/MkSelect.vue';
import { useMkSelect } from '@/composables/use-mkselect.js';
withDefaults(defineProps<{
withMuteType?: boolean;
}>(), {
withMuteType: false,
});
const emit = defineEmits<{
(ev: 'done', v: MkMuteSettingDialogDoneEvent): void;
(ev: 'closed'): void;
}>();
const modal = useTemplateRef('modal');
const {
def: periodDef,
model: periodModel,
} = useMkSelect({
items: periodItems,
initialValue: 'indefinitely',
});
const now = Date.now();
const manualExpiresAt = ref<string | null>(null);
const canSave = computed(() => {
if (periodModel.value === 'custom') {
return manualExpiresAt.value != null && new Date(manualExpiresAt.value).getTime() > now;
}
return true;
});
const {
def: muteTypeDef,
model: muteTypeModel,
} = useMkSelect({
items: muteTypeItems,
initialValue: 'all',
});
// overload function を使いたいので lint エラーを無視する
function done(canceled: true): void;
function done(canceled: false, period: typeof periodModel.value, type: typeof muteTypeModel.value): void; // eslint-disable-line no-redeclare
function done(canceled: boolean, period?: typeof periodModel.value, type?: typeof muteTypeModel.value) { // eslint-disable-line no-redeclare
const expiresAt = (() => {
if (canceled) return null;
if (period === 'custom' && manualExpiresAt.value != null) {
return new Date(manualExpiresAt.value!).getTime();
}
const now = Date.now();
switch (period) {
case 'indefinitely':
return null;
case 'tenMinutes':
return now + 10 * 60 * 1000;
case 'oneHour':
return now + 60 * 60 * 1000;
case 'oneDay':
return now + 24 * 60 * 60 * 1000;
case 'oneWeek':
return now + 7 * 24 * 60 * 60 * 1000;
default:
return null;
}
})();
if (canceled) {
emit('done', { canceled: true });
} else {
emit('done', { canceled: false, expiresAt, type: type! });
}
modal.value?.close();
}
async function ok() {
done(false, periodModel.value, muteTypeModel.value);
}
function cancel() {
done(true);
}
/*
function onBgClick() {
if (props.cancelableByBgClick) cancel();
}
*/
function onKeydown(evt: KeyboardEvent) {
if (evt.key === 'Escape') cancel();
}
onMounted(() => {
window.document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
window.document.removeEventListener('keydown', onKeydown);
});
</script>
<style lang="scss" module>
.root {
position: relative;
margin: auto;
padding: 32px;
width: 100%;
min-width: 320px;
max-width: 480px;
box-sizing: border-box;
background: var(--MI_THEME-panel);
border-radius: 16px;
}
.header {
display: flex;
align-items: center;
gap: 0.75em;
}
.icon {
font-size: 18px;
color: var(--MI_THEME-warn);
}
.title {
font-weight: bold;
font-size: 1.1em;
}
.buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: right;
}
</style>
@@ -0,0 +1,187 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')">
<div :class="$style.root">
<div v-if="title" class="_selectable" :class="$style.header">
<Mfm :text="title"/>
</div>
<div v-if="text" :class="$style.text" class="_selectable">
<Mfm :text="text"/>
</div>
<div class="_gaps_s">
<MkSelect v-model="periodModel" :items="periodDef"></MkSelect>
<MkInput
v-if="periodModel === 'custom'"
v-model="manualExpiresAt"
type="datetime-local"
></MkInput>
</div>
<div :class="$style.buttons">
<MkButton inline rounded @click="cancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton inline primary rounded :disabled="!canSave" @click="ok">{{ i18n.ts.ok }}</MkButton>
</div>
</div>
</MkModal>
</template>
<script lang="ts">
import { i18n } from '@/i18n.js';
import type { MkSelectItem } from '@/components/MkSelect.vue';
import MkInput from './MkInput.vue';
const periodItems = [{
value: 'indefinitely', label: i18n.ts.indefinitely,
}, {
value: 'tenMinutes', label: i18n.ts.tenMinutes,
}, {
value: 'oneHour', label: i18n.ts.oneHour,
}, {
value: 'oneDay', label: i18n.ts.oneDay,
}, {
value: 'oneWeek', label: i18n.ts.oneWeek,
}, {
value: 'custom', label: i18n.ts.custom,
}] as const satisfies MkSelectItem[];
export type MkPeriodDialogDoneEvent = { canceled: true } | { canceled: false, expiresAt: number | null };
</script>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, useTemplateRef, ref, computed } from 'vue';
import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue';
import MkSelect from '@/components/MkSelect.vue';
import { useMkSelect } from '@/composables/use-mkselect.js';
defineProps<{
title?: string;
text?: string;
}>();
const emit = defineEmits<{
(ev: 'done', v: MkPeriodDialogDoneEvent): void;
(ev: 'closed'): void;
}>();
const modal = useTemplateRef('modal');
const {
def: periodDef,
model: periodModel,
} = useMkSelect({
items: periodItems,
initialValue: 'indefinitely',
});
const now = Date.now();
const manualExpiresAt = ref<string | null>(null);
const canSave = computed(() => {
if (periodModel.value === 'custom') {
return manualExpiresAt.value != null && new Date(manualExpiresAt.value).getTime() > now;
}
return true;
});
// overload function を使いたいので lint エラーを無視する
function done(canceled: true): void;
function done(canceled: false, period: typeof periodModel.value): void; // eslint-disable-line no-redeclare
function done(canceled: boolean, period?: typeof periodModel.value) { // eslint-disable-line no-redeclare
const expiresAt = (() => {
if (canceled) return null;
if (period === 'custom' && manualExpiresAt.value != null) {
return new Date(manualExpiresAt.value!).getTime();
}
const now = Date.now();
switch (period) {
case 'indefinitely':
return null;
case 'tenMinutes':
return now + 10 * 60 * 1000;
case 'oneHour':
return now + 60 * 60 * 1000;
case 'oneDay':
return now + 24 * 60 * 60 * 1000;
case 'oneWeek':
return now + 7 * 24 * 60 * 60 * 1000;
default:
return null;
}
})();
if (canceled) {
emit('done', { canceled: true });
} else {
emit('done', { canceled: false, expiresAt });
}
modal.value?.close();
}
async function ok() {
done(false, periodModel.value);
}
function cancel() {
done(true);
}
/*
function onBgClick() {
if (props.cancelableByBgClick) cancel();
}
*/
function onKeydown(evt: KeyboardEvent) {
if (evt.key === 'Escape') cancel();
}
onMounted(() => {
window.document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
window.document.removeEventListener('keydown', onKeydown);
});
</script>
<style lang="scss" module>
.root {
position: relative;
margin: auto;
padding: 32px;
min-width: 320px;
max-width: 480px;
box-sizing: border-box;
background: var(--MI_THEME-panel);
border-radius: 16px;
}
.header {
margin: 0 0 8px 0;
text-align: center;
font-weight: bold;
font-size: 1.1em;
& + .text {
margin-top: 8px;
}
}
.text {
margin: 16px 0 0 0;
}
.buttons {
margin-top: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
</style>
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
:class="[$style.root]"
:class="[$style.root, { [$style.modal]: modal, _popup: modal }]"
@dragover.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@@ -114,7 +114,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, onUnmounted } from 'vue';
import { inject, 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,6 +161,8 @@ import { closeTip } from '@/tips.js';
const $i = ensureSignin();
const modal = inject(DI.inModal, false);
const props = withDefaults(defineProps<PostFormProps & {
fixed?: boolean;
autofocus?: boolean;
@@ -1445,6 +1447,13 @@ defineExpose({
.root {
position: relative;
container-type: inline-size;
&.modal {
width: 100%;
max-width: 520px;
overflow-x: clip;
overflow-y: auto;
}
}
//#region header
@@ -1713,8 +1722,7 @@ html[data-color-scheme=light] .preview {
min-width: 100%;
width: 100%;
min-height: 90px;
max-height: 500px;
field-sizing: content;
height: 100%;
}
.textCount {
@@ -14,7 +14,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPostForm
ref="form"
:class="$style.form"
class="_popup"
v-bind="props"
autofocus
freezeAfterPosted
@@ -74,8 +73,7 @@ function onModalClosed() {
<style lang="scss" module>
.form {
width: 100%;
max-width: 520px;
max-height: 100%;
margin: 0 auto auto auto;
}
</style>
+14
View File
@@ -15,6 +15,7 @@ import type { MenuItem } from '@/types/menu.js';
import type { PostFormProps } from '@/types/post-form.js';
import type { UploaderFeatures } from '@/composables/use-uploader.js';
import type { MkSelectItem, OptionValue } from '@/components/MkSelect.vue';
import type { MkPeriodDialogDoneEvent } from '@/components/MkPeriodDialog.vue';
import type MkRoleSelectDialog_TypeReferenceOnly from '@/components/MkRoleSelectDialog.vue';
import type MkEmojiPickerDialog_TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue';
import { misskeyApi } from '@/utility/misskey-api.js';
@@ -530,6 +531,19 @@ export function select<C extends OptionValue, D extends C | null = null>(props:
});
}
export function selectPeriod(options: { title?: string } = {}): Promise<MkPeriodDialogDoneEvent> {
return new Promise(async (resolve) => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkPeriodDialog.vue')), {
title: options.title,
}, {
done: result => {
resolve(result ? result : { canceled: true });
},
closed: () => dispose(),
});
});
}
export function success(): Promise<void> {
return new Promise(resolve => {
const showing = ref(true);
+4 -22
View File
@@ -447,31 +447,13 @@ async function assignRole() {
});
if (canceled || roleId == null) return;
const { canceled: canceled2, result: period } = await os.select({
title: i18n.ts.period + ': ' + roles.find(r => r.id === roleId)!.name,
items: [{
value: 'indefinitely', label: i18n.ts.indefinitely,
}, {
value: 'oneHour', label: i18n.ts.oneHour,
}, {
value: 'oneDay', label: i18n.ts.oneDay,
}, {
value: 'oneWeek', label: i18n.ts.oneWeek,
}, {
value: 'oneMonth', label: i18n.ts.oneMonth,
}],
default: 'indefinitely',
const res = await os.selectPeriod({
title: `${i18n.ts.period}: ${roles.find(r => r.id === roleId)!.name}`,
});
if (canceled2) return;
const expiresAt = period === 'indefinitely' ? null
: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
: period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30)
: null;
if (res.canceled) return;
await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.value.id, expiresAt });
await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.value.id, expiresAt: res.expiresAt });
refreshUser();
}
@@ -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></template>
<template #label><SearchLabel>Misskey® Reactions Boost Technology™ (RBT)</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template>
<template v-if="rbtForm.savedState.enableReactionsBuffering" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="rbtForm.modified.value" #footer>
@@ -112,31 +112,12 @@ async function del() {
async function assign() {
const user = await os.selectUser({ includeSelf: true });
const { canceled: canceled2, result: period } = await os.select({
title: i18n.ts.period + ': ' + role.name,
items: [{
value: 'indefinitely', label: i18n.ts.indefinitely,
}, {
value: 'oneHour', label: i18n.ts.oneHour,
}, {
value: 'oneDay', label: i18n.ts.oneDay,
}, {
value: 'oneWeek', label: i18n.ts.oneWeek,
}, {
value: 'oneMonth', label: i18n.ts.oneMonth,
}],
default: 'indefinitely',
const res = await os.selectPeriod({
title: `${i18n.ts.period}: ${role.name}`,
});
if (canceled2) return;
if (res.canceled) return;
const expiresAt = period === 'indefinitely' ? null
: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
: period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30)
: null;
await os.apiWithDialog('admin/roles/assign', { roleId: role.id, userId: user.id, expiresAt });
await os.apiWithDialog('admin/roles/assign', { roleId: role.id, userId: user.id, expiresAt: res.expiresAt });
//role.users.push(user);
}
+5 -23
View File
@@ -81,6 +81,7 @@ import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'
import XChannelFollowButton from '@/components/MkChannelFollowButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { openMuteSettingDialog } from '@/utility/mute-confirm.js';
import { $i, iAmModerator } from '@/i.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
@@ -195,33 +196,14 @@ async function mute() {
if (!channel.value) return;
const _channel = channel.value;
const { canceled, result: period } = await os.select({
title: i18n.ts.mutePeriod,
items: [{
value: 'indefinitely', label: i18n.ts.indefinitely,
}, {
value: 'tenMinutes', label: i18n.ts.tenMinutes,
}, {
value: 'oneHour', label: i18n.ts.oneHour,
}, {
value: 'oneDay', label: i18n.ts.oneDay,
}, {
value: 'oneWeek', label: i18n.ts.oneWeek,
}],
default: 'indefinitely',
const res = await openMuteSettingDialog({
withMuteType: false,
});
if (canceled) return;
const expiresAt = period === 'indefinitely' ? null
: period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10)
: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
: null;
if (res.canceled) return;
os.apiWithDialog('channels/mute/create', {
channelId: _channel.id,
expiresAt,
expiresAt: res.expiresAt,
}).then(() => {
_channel.isMuting = true;
});
@@ -11,11 +11,6 @@ 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>
@@ -51,7 +46,6 @@ 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,8 +142,6 @@ 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>
@@ -165,7 +163,7 @@ import MkKeyValue from '@/components/MkKeyValue.vue';
import MkButton from '@/components/MkButton.vue';
import FormSlot from '@/components/form/slot.vue';
import * as os from '@/os.js';
import { enableStoragePersistence, storagePersisted, skipStoragePersistence } from '@/utility/storage.js';
import { misskeyApi } from '@/utility/misskey-api.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></template>
<template #label><SearchLabel>{{ i18n.ts.lockdown }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></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></template>
<template #label><SearchLabel>{{ i18n.ts._profile.followedMessage }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></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 :forceDisableInfiniteScroll="true">
<MkPagination :paginator="paginator" withControl>
<template #default="{items}">
<div>
<div v-for="item in items" :key="item.id" v-panel class="timnmucd">
+9 -12
View File
@@ -257,23 +257,20 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
this.rewriteRawState(key, v);
const record = this.getMatchedRecordOf(key);
this.emit('committed', {
key,
value: v,
oldValue: this.s[key],
});
const _save = () => {
this.save();
this.emit('committed', {
key,
value: v,
oldValue: this.s[key],
});
};
const record = this.getMatchedRecordOf(key);
if (parseScope(record[0]).account == null && isAccountDependentKey(key) && currentAccount != null) {
this.profile.preferences[key].push([makeScope({
server: host,
account: currentAccount.id,
}), v, {}]);
_save();
this.save();
return;
}
@@ -281,12 +278,12 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
this.profile.preferences[key].push([makeScope({
server: host,
}), v, {}]);
_save();
this.save();
return;
}
record[1] = v;
_save();
this.save();
if (record[2].sync) {
// awaitの必要なし
-4
View File
@@ -118,10 +118,6 @@ export const store = markRaw(new Pizzax('base', {
where: 'device',
default: true,
},
showStoragePersistenceSuggestion: {
where: 'device',
default: true,
},
//#region TODO: そのうち消す (preferに移行済み)
defaultWithReplies: {
-1
View File
@@ -12,7 +12,6 @@ export const TIPS = [
'clips',
'userLists',
'postForm',
'deck',
'tl.home',
'tl.local',
'tl.social',
+1 -10
View File
@@ -14,9 +14,6 @@ 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>
@@ -54,7 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkA>
</div>
<div :class="$style.bottom">
<button v-if="iconOnly && showWidgetButton" v-tooltip.noDelay.right="i18n.ts.widgets" class="_button" :class="[$style.widget]" @click="() => emit('widgetButtonClick')">
<button v-if="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">
@@ -439,12 +436,6 @@ function menuEdit() {
}
}
.widget {
display: inline-block;
width: var(--top-height);
margin-left: auto;
}
.bottom {
position: sticky;
bottom: 0;
+9 -43
View File
@@ -38,39 +38,36 @@ SPDX-License-Identifier: AGPL-3.0-only
@headerWheel="onWheel"
/>
</section>
<div v-if="layout.length === 0" class="_panel _gaps" :class="$style.onboarding">
<div v-if="layout.length === 0" class="_panel" :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 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.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 ref="addColumnButtonEl" v-tooltip.noDelay.left="i18n.ts._deck.addColumn" :class="$style.sideMenuButton" class="_button" @click="addColumn"><i class="ti ti-plus"></i></button>
<button 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 ref="settingsButtonEl" v-tooltip.noDelay.left="i18n.ts.settings" :class="$style.sideMenuButton" class="_button" @click="showSettings"><i class="ti ti-settings-2"></i></button>
<button 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 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>
<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>
</div>
<div :class="$style.bottomMenuMiddle">
<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>
<button v-tooltip.noDelay.left="i18n.ts._deck.addColumn" :class="$style.bottomMenuButton" class="_button" @click="addColumn"><i class="ti ti-plus"></i></button>
</div>
<div :class="$style.bottomMenuRight">
<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>
<button v-tooltip.noDelay.left="i18n.ts.settings" :class="$style.bottomMenuButton" class="_button" @click="showSettings"><i class="ti ti-settings-2"></i></button>
</div>
</div>
@@ -99,7 +96,6 @@ 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';
@@ -111,13 +107,10 @@ 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'));
@@ -170,9 +163,6 @@ 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({
@@ -228,30 +218,6 @@ 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>
@@ -379,7 +345,7 @@ window.document.documentElement.style.scrollBehavior = 'auto';
}
.bottomMenuButton {
display: inline-block;
display: block;
height: 100%;
aspect-ratio: 1;
}
+11 -4
View File
@@ -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="!showWidgetsSide" @widgetButtonClick="widgetsShowing = true"/>
<XSidebar v-if="!isMobile" :class="$style.sidebar" :showWidgetButton="!isDesktop" @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="showWidgetsSide && !pageMetadata?.needWideArea" :class="$style.widgets">
<div v-if="isDesktop && !pageMetadata?.needWideArea" :class="$style.widgets">
<XWidgets/>
</div>
</div>
@@ -64,8 +64,7 @@ const DESKTOP_THRESHOLD = 1100;
const MOBILE_THRESHOLD = 500;
// デスクトップでウィンドウを狭くしたときモバイルUIが表示されて欲しいことはあるので deviceKind === 'desktop' の判定は行わない
const showWidgetsSide = window.innerWidth >= DESKTOP_THRESHOLD;
const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD);
const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD);
window.addEventListener('resize', () => {
isMobile.value = deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD;
@@ -103,6 +102,14 @@ 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;
+10 -45
View File
@@ -18,6 +18,7 @@ import { notesSearchAvailable, canSearchNonLocalNotes } from '@/utility/check-pe
import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
import { mainRouter } from '@/router.js';
import { genEmbedCode } from '@/utility/get-embed-code.js';
import { openMuteSettingDialog } from '@/utility/mute-confirm.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
@@ -34,33 +35,15 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
user.isMuted = false;
});
} else {
const { canceled, result: period } = await os.select({
title: i18n.ts.mutePeriod,
items: [{
value: 'indefinitely', label: i18n.ts.indefinitely,
}, {
value: 'tenMinutes', label: i18n.ts.tenMinutes,
}, {
value: 'oneHour', label: i18n.ts.oneHour,
}, {
value: 'oneDay', label: i18n.ts.oneDay,
}, {
value: 'oneWeek', label: i18n.ts.oneWeek,
}],
default: 'indefinitely',
const res = await openMuteSettingDialog({
withMuteType: true,
});
if (canceled) return;
const expiresAt = period === 'indefinitely' ? null
: period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10)
: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
: null;
if (res.canceled) return;
os.apiWithDialog('mute/create', {
userId: user.id,
expiresAt,
expiresAt: res.expiresAt,
mutingType: res.type,
}).then(() => {
user.isMuted = true;
});
@@ -324,31 +307,13 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
return roles.filter(r => r.target === 'manual').map(r => ({
text: r.name,
action: async () => {
const { canceled, result: period } = await os.select({
title: i18n.ts.period + ': ' + r.name,
items: [{
value: 'indefinitely', label: i18n.ts.indefinitely,
}, {
value: 'oneHour', label: i18n.ts.oneHour,
}, {
value: 'oneDay', label: i18n.ts.oneDay,
}, {
value: 'oneWeek', label: i18n.ts.oneWeek,
}, {
value: 'oneMonth', label: i18n.ts.oneMonth,
}],
default: 'indefinitely',
const res = await os.selectPeriod({
title: `${i18n.ts.period}: ${r.name}`,
});
if (canceled) return;
const expiresAt = period === 'indefinitely' ? null
: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
: period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30)
: null;
if (res.canceled) return;
os.apiWithDialog('admin/roles/assign', { roleId: r.id, userId: user.id, expiresAt });
os.apiWithDialog('admin/roles/assign', { roleId: r.id, userId: user.id, expiresAt: res.expiresAt });
},
}));
},
@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent } from 'vue';
import * as os from '@/os.js';
import type { MkMuteSettingDialogDoneEvent } from '@/components/MkMuteSettingDialog.vue';
export function openMuteSettingDialog(opts?: { withMuteType?: boolean }): Promise<MkMuteSettingDialogDoneEvent> {
return new Promise(resolve => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkMuteSettingDialog.vue')), opts ?? {}, {
done: result => {
resolve(result ? result : { canceled: true });
},
closed: () => {
dispose();
},
});
});
}
-34
View File
@@ -1,34 +0,0 @@
/*
* 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);
}
+20 -42
View File
@@ -596,6 +596,18 @@ export interface Locale extends ILocale {
* ミュート
*/
"mute": string;
/**
* ミュートする範囲
*/
"muteType": string;
/**
* ミュートを適用する範囲を設定できます。「タイムラインのみ」に設定すると、タイムラインや検索結果上からは見えなくなりますが、通知は受け取ります。
*/
"muteTypeDescription": string;
/**
* タイムラインのみ
*/
"muteTypeTimeline": string;
/**
* ミュート解除
*/
@@ -3836,6 +3848,10 @@ export interface Locale extends ILocale {
* ミュートする期限
*/
"mutePeriod": string;
/**
* 期限はあくまで目安です。反映が数分遅れる場合があります。
*/
"mutePeriodDescription": string;
/**
* 期限
*/
@@ -5639,6 +5655,10 @@ export interface Locale extends ILocale {
* ゼロ埋め
*/
"zeroPadding": string;
/**
* ミュートしますか?
*/
"muteConfirm": string;
"_imageEditing": {
"_vars": {
/**
@@ -6193,18 +6213,6 @@ export interface Locale extends ILocale {
* アニメーション画像を有効にする
*/
"enableAnimatedImages": string;
/**
* 設定の永続化
*/
"settingsPersistence_title": string;
/**
* 設定の永続化を有効にすると、設定情報が失われるのを防止できます。
*/
"settingsPersistence_description1": string;
/**
* 環境によっては有効化できない場合があります。
*/
"settingsPersistence_description2": string;
"_chat": {
/**
* 送信者の名前を表示
@@ -10948,36 +10956,6 @@ 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 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2025.12.2",
"version": "2025.12.2-beta.0",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",
+8
View File
@@ -4824,6 +4824,8 @@ export type components = {
createdAt: string;
/** Format: date-time */
expiresAt: string | null;
/** @enum {string} */
mutingType: 'all' | 'timelineOnly';
/** Format: id */
muteeId: string;
mutee: components['schemas']['UserDetailedNotMe'];
@@ -28619,6 +28621,12 @@ export interface operations {
userId: string;
/** @description A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute. */
expiresAt?: number | null;
/**
* @description Type of muting. `all` mutes everything including notifications. `timelineOnly` mutes only timeline and search, but allows notifications.
* @default all
* @enum {string}
*/
mutingType?: 'all' | 'timelineOnly';
};
};
};
+314 -294
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -35,4 +35,5 @@ ignorePatchFailures: false
minimumReleaseAge: 10080 # delay 7days to mitigate supply-chain attack
minimumReleaseAgeExclude:
- '@syuilo/aiscript'
- systeminformation # 脆弱性対応。そのうち消すこと
- vue-tsc
- '@vue/language-core'