1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-03 02:14:49 +02:00

Compare commits

..

25 Commits

Author SHA1 Message Date
mattyatea
510952f861 fix: move changelog entries to latest release 2026-06-29 23:23:03 +09:00
mattyatea
e90f664922 fix: address PR CI failures 2026-06-29 23:14:45 +09:00
mattyatea
4cca0c220e chore: remove local agent harness files 2026-06-29 22:58:03 +09:00
mattyatea
be9762c169 fix: address role display review issues 2026-06-29 22:47:26 +09:00
mattyatea
c1bd11eada Merge remote-tracking branch 'origin/develop' into feature/role-hidden-setting 2026-06-29 22:26:20 +09:00
mattyatea
2debf09bf2 Tighten user entity test types 2026-06-29 22:23:26 +09:00
syuilo
62f8589c05 Update CONTRIBUTING.md 2026-06-28 17:52:08 +09:00
かっこかり
6193c35f9f fix: stats API の型に reactionCount が定義されていない問題を修正 (#17634)
* fix: stats API の型に reactionCount が定義されていない問題を修正

* Update Changelog
2026-06-28 01:49:00 +09:00
かっこかり
1220f05903 Update CHANGELOG.md 2026-06-28 01:02:03 +09:00
かっこかり
7544ebf7a3 Update CHANGELOG.md 2026-06-28 00:54:52 +09:00
かっこかり
ffe65caf10 Update CHANGELOG.md 2026-06-28 00:25:42 +09:00
かっこかり
4f993cef1b enhance: Set default Node.js version to v26 (#17623)
* enhance: Set default Node.js version to v26

* fix

* Update Changelog

* fix types

* drop node v22

* update changelog

* fix test

* update

* fix test

* fix test

* Revert "drop node v22"

This reverts commit bb4e011628.

* fix changelog

* attempt to fix test

* attempt to fix test

* fix: update re2 that supports node 26

* attempt to fix test

* attempt to fix test

* run pnpm dedupe

* restore 2fa e2e

* refactor

* attempt to fix test

* attempt to fix test

* run pnpm dedupe

* attempt to fix test
2026-06-28 00:06:49 +09:00
かっこかり
ba3fb4aa14 Merge branch 'master' into develop 2026-06-27 23:40:06 +09:00
かっこかり
0137b1c406 fix: update compatible node version on master (#17632)
Update package.json
2026-06-27 23:39:29 +09:00
かっこかり
797dec7d0e Update CHANGELOG.md 2026-06-27 23:36:35 +09:00
mattyatea
a4a3606435 fixup! 4263f62e7b
Created by IntelliJ Git plugin for drop selected changes operation
2026-05-23 21:01:38 +09:00
mattyatea
9f3d80a97a Merge remote-tracking branch 'origin/develop' into feature/role-hidden-setting
# Conflicts:
#	CHANGELOG.md
2026-05-23 20:49:25 +09:00
mattyatea
e57e88ebc6 feat(frontend): ロール設定ページを追加 2026-05-23 20:24:40 +09:00
mattyatea
d490936bf6 fix(frontend): 非公開ロールの常時表示設定を隠す 2026-05-23 20:24:40 +09:00
mattyatea
63a8520191 fix(backend): 非表示ロールのバッジを返さないように 2026-05-23 20:24:40 +09:00
mattyatea
f88e647a83 docs: ロール表示設定のCHANGELOGを更新 2026-05-23 20:24:39 +09:00
mattyatea
4263f62e7b test: ロール表示設定のテストを追加 2026-05-23 20:24:39 +09:00
mattyatea
d0b8940723 feat(frontend): ロール表示設定を追加 2026-05-23 20:24:39 +09:00
mattyatea
f8e28df711 feat(backend): 公開ロールの非表示設定に対応 2026-05-23 20:24:39 +09:00
mattyatea
19605600d3 feat(backend): ロールの常時表示設定を追加 2026-05-23 20:24:39 +09:00
53 changed files with 968 additions and 482 deletions

View File

@@ -1 +1 @@
22.23.1
26.4.0

View File

@@ -1,16 +1,21 @@
## 2026.6.1
### Note
**今回のリリースではMisskeyの各種動作要件が変更されます。必ずアップグレード前にお使いの環境をご確認ください。**
### Note
- センシティブメディアの判定 (NSFW検出) が、本体に内蔵された nsfwjs による推論から、外部サービス [sensitive-detector](https://github.com/misskey-dev/sensitive-detector) への HTTP 呼び出し方式に変更されました。
- これに伴い、本体から `nsfwjs` / `@tensorflow/tfjs` / `@tensorflow/tfjs-node` および同梱の NSFW 判定モデルが削除され、インストール要件 (ネイティブ ML スタック) が緩和されました。
- **センシティブ判定機能を利用しているサーバーは対応が必要です。** 別途 [sensitive-detector](https://github.com/misskey-dev/sensitive-detector) サービスを立ち上げ、コントロールパネルの「モデレーション > センシティブなメディアの検出」で接続先 URL を設定してください。接続先が未設定の場合、センシティブ判定は行われません (すべて非センシティブ扱い)。
- 画像の正規化・動画フレームの抽出・しきい値判定・集約は引き続き本体側で行われ、外部サービスには正規化済み画像の推論のみを委譲します。
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。
- Node.jsの最低サポートバージョンを 22.22.2 に引き上げました。これより古いバージョンの Node.js では Misskey が起動しません
- Node.js v24, v26 をサポートしました。**Node.js v22 でも動作しますが、今後のリリースで v22 のサポートを終了する予定**ですので、Node.js のアップデートをご検討ください。
- Node.js のセキュリティアップデートに伴い、最低動作バージョンを 22.22.2 / 24.17.0 / 26.4.0 に引き上げました
- Docker Image は Node.js 26.4.0-trixie に更新されています。
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。
### General
- Feat: 公開ロールとロールバッジを個別に非表示にできるようにしました
- Feat: 公開ロール/ロールバッジを常に表示する管理者設定を追加しました
- Feat: コントロールパネルから二要素認証を解除できるように
### Client
@@ -19,7 +24,9 @@
### Server
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
- Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように
- Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新
- Fix: `/stats` API のレスポンス型が正しくない問題を修正
## 2026.6.0

View File

@@ -600,6 +600,90 @@ TypeScriptでjsonをimportすると、tscでコンパイルするときにその
コンポーネント自身がmarginを設定するのは問題の元となることはよく知られている
marginはそのコンポーネントを使う側が設定する
### 命名規則
本来それが略称であっても、通常それでひとつのワードとして用いられるものは、略称として扱わない。
#### 例: IP address
Good: `ipAddress` / `IpAddress`
Bad: `IPAddress`
#### 例: User ID
Good: `userId` / `UserId`
Bad: `userID` / `UserID`
#### 例: XMLなHTTPのRequest
Good: `xmlHttpRequest` / `XmlHttpRequest`
Bad: `XMLHttpRequest` / `XMLHTTPRequest`
### 関数化の基準
汎用性が低く(例えばそれを関数化したとしてもその呼び出しが元の場所一か所しか存在しない)、内容も短い処理(例えば10行以下)は、かえって読みにくくなるため、関数化しない。
また、関数化する場合でも、呼び出しがある特定のスコープに限られる場合は、そのスコープ内に閉じ込めた方が分かりやすく簡潔になる場合がある(ただし本来その処理に不要であっても、構造上親のスコープにある関係のない変数や引数にもアクセスできるようになるため、必ずしもそうすれば設計上綺麗になるというわけでもない。状況に応じて判断すべし)。
Bad:
``` ts
function withBrankets(x) {
return `(${x})`;
}
function formatPercent(x) {
return `${x}%`;
}
function formatValue(x) {
return withBrankets(formatPercent(x));
}
function showData(a, b) {
console.log(formatValue(a));
console.log(formatValue(b));
}
```
Good:
``` ts
function formatValue(x) {
return `(${x}%)`;
}
function showData(a, b) {
console.log(formatValue(a));
console.log(formatValue(b));
}
```
or
``` ts
function showData(a, b) {
function formatValue(x) {
return `(${x}%)`;
}
console.log(formatValue(a));
console.log(formatValue(b));
}
```
or
``` ts
function showData(a, b) {
console.log(`(${a}%)`);
console.log(`(${b}%)`);
}
```
## その他
### HTMLのクラス名で follow という単語は使わない
広告ブロッカーで誤ってブロックされる

View File

@@ -1,6 +1,6 @@
# syntax = docker/dockerfile:1.23
ARG NODE_VERSION=22.22.2-bookworm
ARG NODE_VERSION=26.4.0-trixie
# build assets & compile TypeScript

View File

@@ -1098,6 +1098,7 @@ likeOnlyForRemote: "全て (リモートはいいねのみ)"
nonSensitiveOnly: "非センシティブのみ"
nonSensitiveOnlyForLocalLikeOnlyForRemote: "非センシティブのみ (リモートはいいねのみ)"
rolesAssignedToMe: "自分に割り当てられたロール"
roleSettings: "ロール設定"
resetPasswordConfirm: "パスワードリセットしますか?"
sensitiveWords: "センシティブワード"
sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。"
@@ -2081,6 +2082,8 @@ _role:
isConditionalRole: "これはコンディショナルロールです。"
isPublic: "公開ロール"
descriptionOfIsPublic: "ユーザーのプロフィールでこのロールが表示されます。"
isPublicDisplayRequired: "非表示を許可しない(常に表示)"
descriptionOfIsPublicDisplayRequired: "有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。"
options: "オプション"
policies: "ポリシー"
baseRole: "ベースロール"
@@ -3243,6 +3246,12 @@ _gridComponent:
patternNotMatch: "この値は{pattern}のパターンに一致しません"
notUnique: "この値は一意である必要があります"
_roleDisplay:
title: "表示するロール/ロールバッジ"
description: "自分のプロフィールやノートに表示する公開ロールを選択します。"
alwaysShownByAdmin: "管理者により常に表示するよう設定されています。"
noRoles: "表示できる公開ロールはありません。"
_roleSelectDialog:
notSelected: "選択されていません"

View File

@@ -66,7 +66,7 @@
"@eslint/js": "9.39.4",
"@misskey-dev/eslint-plugin": "2.1.0",
"@types/js-yaml": "4.0.9",
"@types/node": "24.13.2",
"@types/node": "26.0.0",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@typescript/native-preview": "7.0.0-dev.20260426.1",

View File

@@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class RoleDisplayVisibility1779035944722 {
name = 'RoleDisplayVisibility1779035944722';
/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "hiddenRoleIds" character varying(32) array NOT NULL DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "role" ADD "isPublicDisplayRequired" boolean NOT NULL DEFAULT false`);
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "isPublicDisplayRequired"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "hiddenRoleIds"`);
}
}

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"engines": {
"node": "^22.22.2 || ^24.10.0"
"node": "^22.22.2 || ^24.17.0 || ^26.4.0"
},
"scripts": {
"start": "pnpm compile-config && node ./built/entry.js",
@@ -160,7 +160,7 @@
"@types/jsonld": "1.5.15",
"@types/mime-types": "3.0.1",
"@types/ms": "2.1.0",
"@types/node": "24.13.2",
"@types/node": "26.0.0",
"@types/nodemailer": "8.0.1",
"@types/pg": "8.20.0",
"@types/qrcode": "1.5.6",
@@ -181,7 +181,7 @@
"@typescript-eslint/parser": "8.61.1",
"@vitest/coverage-v8": "4.1.9",
"aws-sdk-client-mock": "4.1.0",
"cbor": "10.0.12",
"cbor2": "2.3.0",
"cross-env": "10.1.0",
"eslint-plugin-import": "2.32.0",
"execa": "9.6.1",

View File

@@ -137,7 +137,8 @@ export class AiService {
try {
const form = new FormData();
for (let i = 0; i < chunk.length; i++) {
form.append(`image${i}`, new Blob([chunk[i]], { type: 'image/png' }), `${i}.png`);
const image = Uint8Array.from(chunk[i]);
form.append(`image${i}`, new Blob([image], { type: 'image/png' }), `${i}.png`);
}
// Content-Type は FormData から boundary 付きで自動設定させるため、手動設定はしない。

View File

@@ -676,6 +676,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
target: values.target,
condFormula: values.condFormula,
isPublic: values.isPublic,
isPublicDisplayRequired: values.isPublicDisplayRequired ?? false,
isAdministrator: values.isAdministrator,
isModerator: values.isModerator,
isExplorable: values.isExplorable,

View File

@@ -55,6 +55,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
makeNotesHiddenBefore: null,
chatScope: 'mutual',
emojis: [],
hiddenRoleIds: [],
score: 0,
host: null,
inbox: null,

View File

@@ -64,6 +64,7 @@ export class RoleEntityService {
target: role.target,
condFormula: role.condFormula,
isPublic: role.isPublic,
isPublicDisplayRequired: role.isPublicDisplayRequired,
isAdministrator: role.isAdministrator,
isModerator: role.isModerator,
isExplorable: role.isExplorable,
@@ -84,4 +85,3 @@ export class RoleEntityService {
return Promise.all(roles.map(x => this.pack(x, me)));
}
}

View File

@@ -14,6 +14,7 @@ import type { Packed } from '@/misc/json-schema.js';
import type { Promiseable } from '@/misc/prelude/await-all.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
import type { MiRole } from '@/models/Role.js';
import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
import {
birthdaySchema,
@@ -402,6 +403,37 @@ export class UserEntityService implements OnModuleInit {
return `${this.config.url}/users/${userId}`;
}
@bindThis
private filterHiddenDisplayRoles<T extends Pick<MiRole, 'id' | 'isPublicDisplayRequired'>>(
roles: T[],
user: MiUser,
iAmModerator: boolean,
shouldFilterHidden: boolean,
): T[] {
if (iAmModerator || !shouldFilterHidden || user.hiddenRoleIds.length === 0) return roles;
const hiddenRoleIds = new Set(user.hiddenRoleIds);
return roles.filter(role => role.isPublicDisplayRequired || !hiddenRoleIds.has(role.id));
}
@bindThis
private sanitizeHiddenRoleIds(roles: Pick<MiRole, 'id' | 'isPublic' | 'isPublicDisplayRequired'>[], user: MiUser): MiRole['id'][] {
const hideableRoleIds = new Set(roles
.filter(role => role.isPublic && !role.isPublicDisplayRequired)
.map(role => role.id));
const sanitizedRoleIds: MiRole['id'][] = [];
const seenRoleIds = new Set<MiRole['id']>();
for (const roleId of user.hiddenRoleIds) {
if (!hideableRoleIds.has(roleId) || seenRoleIds.has(roleId)) continue;
sanitizedRoleIds.push(roleId);
seenRoleIds.add(roleId);
}
return sanitizedRoleIds;
}
public async pack<S extends 'MeDetailed' | 'UserDetailedNotMe' | 'UserDetailed' | 'UserLite' = 'UserLite'>(
src: MiUser['id'] | MiUser,
me?: { id: MiUser['id']; } | null | undefined,
@@ -425,6 +457,7 @@ export class UserEntityService implements OnModuleInit {
const meId = me ? me.id : null;
const isMe = meId === user.id;
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
const userRoles = isDetailed ? this.roleService.getUserRoles(user.id) : null;
const profile = isDetailed
? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
@@ -514,10 +547,10 @@ export class UserEntityService implements OnModuleInit {
emojis: this.customEmojiService.populateEmojis(user.emojis, user.host),
onlineStatus: this.getOnlineStatus(user),
// パフォーマンス上の理由で、明示的に設定しない場合はローカルユーザーのみ取得
badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs
.filter((r) => r.isPublic || iAmModerator)
badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => this.filterHiddenDisplayRoles(rs.filter((r) => r.isPublic || iAmModerator), user, iAmModerator, true)
.sort((a, b) => b.displayOrder - a.displayOrder)
.map((r) => ({
id: r.id,
name: r.name,
iconUrl: r.iconUrl,
displayOrder: r.displayOrder,
@@ -560,7 +593,7 @@ export class UserEntityService implements OnModuleInit {
followingVisibility: profile!.followingVisibility,
chatScope: user.chatScope,
canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'),
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
roles: userRoles!.then(roles => this.filterHiddenDisplayRoles(roles.filter(role => role.isPublic), user, iAmModerator, !isMe).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
id: role.id,
name: role.name,
color: role.color,
@@ -568,6 +601,8 @@ export class UserEntityService implements OnModuleInit {
description: role.description,
isModerator: role.isModerator,
isAdministrator: role.isAdministrator,
asBadge: role.asBadge,
isPublicDisplayRequired: role.isPublicDisplayRequired,
displayOrder: role.displayOrder,
}))),
memo: memo,
@@ -598,6 +633,7 @@ export class UserEntityService implements OnModuleInit {
preventAiLearning: profile!.preventAiLearning,
isExplorable: user.isExplorable,
isDeleted: user.isDeleted,
hiddenRoleIds: userRoles!.then(roles => this.sanitizeHiddenRoleIds(roles, user)),
twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
hideOnlineStatus: user.hideOnlineStatus,
hasUnreadSpecifiedNotes: false, // 後方互換性のため

View File

@@ -227,6 +227,11 @@ export class MiRole {
})
public isPublic: boolean;
@Column('boolean', {
default: false,
})
public isPublicDisplayRequired: boolean;
// trueの場合ユーザー名の横にバッジとして表示
@Column('boolean', {
default: false,

View File

@@ -6,6 +6,7 @@
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm';
import { id } from './util/id.js';
import { MiDriveFile } from './DriveFile.js';
import type { MiRole } from './Role.js';
@Entity('user')
@Index(['usernameLower', 'host'], { unique: true })
@@ -229,6 +230,11 @@ export class MiUser {
})
public emojis: string[];
@Column('varchar', {
length: 32, array: true, default: '{}',
})
public hiddenRoleIds: MiRole['id'][];
// チャットを許可する相手
// everyone: 誰からでも
// followers: フォロワーのみ

View File

@@ -369,6 +369,16 @@ export const packedRoleLiteSchema = {
optional: false, nullable: false,
example: false,
},
asBadge: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
isPublicDisplayRequired: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
displayOrder: {
type: 'integer',
optional: false, nullable: false,
@@ -412,6 +422,11 @@ export const packedRoleSchema = {
optional: false, nullable: false,
example: false,
},
isPublicDisplayRequired: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
isExplorable: {
type: 'boolean',
optional: false, nullable: false,

View File

@@ -176,6 +176,11 @@ export const packedUserLiteSchema = {
type: 'object',
nullable: false, optional: false,
properties: {
id: {
type: 'string',
nullable: false, optional: false,
format: 'id',
},
name: {
type: 'string',
nullable: false, optional: false,
@@ -511,6 +516,15 @@ export const packedMeDetailedOnlySchema = {
type: 'boolean',
nullable: false, optional: false,
},
hiddenRoleIds: {
type: 'array',
nullable: false, optional: false,
items: {
type: 'string',
nullable: false, optional: false,
format: 'id',
},
},
twoFactorBackupCodesStock: {
type: 'string',
enum: ['full', 'partial', 'none'],

View File

@@ -32,6 +32,7 @@ export const paramDef = {
target: { type: 'string', enum: ['manual', 'conditional'] },
condFormula: { type: 'object' },
isPublic: { type: 'boolean' },
isPublicDisplayRequired: { type: 'boolean', default: false },
isModerator: { type: 'boolean' },
isAdministrator: { type: 'boolean' },
isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility

View File

@@ -37,6 +37,7 @@ export const paramDef = {
target: { type: 'string', enum: ['manual', 'conditional'] },
condFormula: { type: 'object' },
isPublic: { type: 'boolean' },
isPublicDisplayRequired: { type: 'boolean' },
isModerator: { type: 'boolean' },
isAdministrator: { type: 'boolean' },
isExplorable: { type: 'boolean' },
@@ -75,6 +76,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
target: ps.target,
condFormula: ps.condFormula,
isPublic: ps.isPublic,
isPublicDisplayRequired: ps.isPublicDisplayRequired,
isModerator: ps.isModerator,
isAdministrator: ps.isAdministrator,
isExplorable: ps.isExplorable,

View File

@@ -191,6 +191,12 @@ export const paramDef = {
followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
chatScope: { type: 'string', enum: ['everyone', 'followers', 'following', 'mutual', 'none'] },
hiddenRoleIds: {
type: 'array',
maxItems: 256,
uniqueItems: true,
items: { type: 'string', format: 'misskey:id' },
},
pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true },
mutedWords: muteWords,
hardMutedWords: muteWords,
@@ -293,6 +299,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility;
if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility;
if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;
if (ps.hiddenRoleIds !== undefined) {
const roles = await this.roleService.getUserRoles(user.id);
const allowedRoleIds = new Set(roles.filter(role => role.isPublic && !role.isPublicDisplayRequired).map(role => role.id));
const hiddenRoleIds: string[] = [];
const seenRoleIds = new Set<string>();
for (const roleId of ps.hiddenRoleIds) {
if (seenRoleIds.has(roleId) || !allowedRoleIds.has(roleId)) continue;
seenRoleIds.add(roleId);
hiddenRoleIds.push(roleId);
}
updates.hiddenRoleIds = hiddenRoleIds;
}
function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
const count = (arr: (string[] | string)[]) => {

View File

@@ -35,6 +35,14 @@ export const meta = {
type: 'number',
optional: false, nullable: false,
},
reactionsCount: {
type: 'number',
optional: false, nullable: false,
},
//originalReactionsCount: {
// type: 'number',
// optional: false, nullable: false,
//},
instances: {
type: 'number',
optional: false, nullable: false,

View File

@@ -7,7 +7,7 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as crypto from 'node:crypto';
import cbor from 'cbor';
import { encode as encodeToCbor } from 'cbor2';
import * as OTPAuth from 'otpauth';
import { loadConfig } from '@/config.js';
import { api, signup, sendEnvUpdateRequest } from '../utils.js';
@@ -61,7 +61,7 @@ describe('2要素認証', () => {
const keyDoneParam = (param: {
token: string,
keyName: string,
credentialId: Buffer,
credentialId: Uint8Array,
creationOptions: PublicKeyCredentialCreationOptionsJSON,
}): {
token: string,
@@ -70,10 +70,10 @@ describe('2要素認証', () => {
credential: RegistrationResponseJSON,
} => {
// A COSE encoded public key
const credentialPublicKey = cbor.encode(new Map<number, unknown>([
const credentialPublicKey = encodeToCbor(new Map<number, unknown>([
[-1, coseEc2CrvP256],
[-2, Buffer.from(coseEc2X, 'hex')],
[-3, Buffer.from(coseEc2Y, 'hex')],
[-2, Uint8Array.from(Buffer.from(coseEc2X, 'hex'))],
[-3, Uint8Array.from(Buffer.from(coseEc2Y, 'hex'))],
[1, coseKtyEc2],
[2, coseKid],
[3, coseAlgEs256],
@@ -85,21 +85,23 @@ describe('2要素認証', () => {
credentialIdLength.writeUInt16BE(param.credentialId.length, 0);
const authData = Buffer.concat([
rpIdHash(), // rpIdHash(32)
Buffer.from([0x45]), // flags(1)
Buffer.from([0x00, 0x00, 0x00, 0x00]), // signCount(4)
Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // AAGUID(16)
new Uint8Array([0x45]), // flags(1)
new Uint8Array(4), // signCount(4)
new Uint8Array(16), // AAGUID(16)
credentialIdLength,
param.credentialId,
credentialPublicKey,
]);
const credentialIdBase64url = Buffer.from(param.credentialId).toString('base64url');
return {
password,
token: param.token,
name: param.keyName,
credential: <RegistrationResponseJSON>{
id: param.credentialId.toString('base64url'),
rawId: param.credentialId.toString('base64url'),
id: credentialIdBase64url,
rawId: credentialIdBase64url,
response: <AuthenticatorAttestationResponseJSON>{
clientDataJSON: Buffer.from(JSON.stringify({
type: 'webauthn.create',
@@ -107,11 +109,11 @@ describe('2要素認証', () => {
origin: config.scheme + '://' + config.host,
androidPackageName: 'org.mozilla.firefox',
}), 'utf-8').toString('base64url'),
attestationObject: cbor.encode({
attestationObject: Buffer.from(encodeToCbor({
fmt: 'none',
attStmt: {},
authData,
}).toString('base64url'),
authData: new Uint8Array(authData),
})).toString('base64url'),
},
clientExtensionResults: {},
type: 'public-key',

View File

@@ -128,6 +128,7 @@ describe('ユーザー', () => {
preventAiLearning: user.preventAiLearning,
isExplorable: user.isExplorable,
isDeleted: user.isDeleted,
hiddenRoleIds: user.hiddenRoleIds ?? [],
twoFactorBackupCodesStock: user.twoFactorBackupCodesStock,
hideOnlineStatus: user.hideOnlineStatus,
hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes,
@@ -660,12 +661,15 @@ describe('ユーザー', () => {
description: rolePublic.description,
isModerator: rolePublic.isModerator,
isAdministrator: rolePublic.isAdministrator,
asBadge: rolePublic.asBadge,
isPublicDisplayRequired: rolePublic.isPublicDisplayRequired,
displayOrder: rolePublic.displayOrder,
}]);
});
test('を取得することができ、バッヂロールがセットされていること', async () => {
const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice });
assert.deepStrictEqual(response.badgeRoles, [{
id: roleBadge.id,
name: roleBadge.name,
iconUrl: roleBadge.iconUrl,
displayOrder: roleBadge.displayOrder,
@@ -678,9 +682,41 @@ describe('ユーザー', () => {
description: roleBadge.description,
isModerator: roleBadge.isModerator,
isAdministrator: roleBadge.isAdministrator,
asBadge: roleBadge.asBadge,
isPublicDisplayRequired: roleBadge.isPublicDisplayRequired,
displayOrder: roleBadge.displayOrder,
}]);
});
test('i/updateでhiddenRoleIdsが保存時に表示可能な割り当て済みロールだけへsanitizeされること', async () => {
const user = await signup({ username: 'userHiddenRoles' });
const visibleRole = await role(root, { isPublic: true, name: 'Hideable Role' });
const secondVisibleRole = await role(root, { isPublic: true, name: 'Second Hideable Role' });
const privateRole = await role(root, { isPublic: false, name: 'Private Role' });
const forcedRole = await role(root, { isPublic: true, isPublicDisplayRequired: true, name: 'Forced Role' });
const unassignedRole = await role(root, { isPublic: true, name: 'Unassigned Role' });
await api('admin/roles/assign', { userId: user.id, roleId: visibleRole.id }, root);
await api('admin/roles/assign', { userId: user.id, roleId: secondVisibleRole.id }, root);
await api('admin/roles/assign', { userId: user.id, roleId: privateRole.id }, root);
await api('admin/roles/assign', { userId: user.id, roleId: forcedRole.id }, root);
const response = await successfulApiCall({
endpoint: 'i/update',
parameters: {
hiddenRoleIds: [
secondVisibleRole.id,
'unknownroleid',
unassignedRole.id,
privateRole.id,
forcedRole.id,
visibleRole.id,
],
},
user,
});
assert.deepStrictEqual(response.hiddenRoleIds, [secondVisibleRole.id, visibleRole.id]);
});
test('をID指定のリスト形式で取得することができる', async () => {
const parameters = { userIds: [] };
const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice });

View File

@@ -14,7 +14,10 @@ import { genAidx } from '@/misc/id/aidx.js';
import {
BlockingsRepository,
FollowingsRepository, FollowRequestsRepository,
MiRole,
MiUserProfile, MutingsRepository, RenoteMutingsRepository,
RoleAssignmentsRepository,
RolesRepository,
UserMemoRepository,
UserProfilesRepository,
UsersRepository,
@@ -67,6 +70,13 @@ describe('UserEntityService', () => {
let blockingRepository: BlockingsRepository;
let mutingRepository: MutingsRepository;
let renoteMutingsRepository: RenoteMutingsRepository;
let rolesRepository: RolesRepository;
let roleAssignmentsRepository: RoleAssignmentsRepository;
let roleService: RoleService;
type RoleServiceCacheController = {
rolesCache: { delete(): void };
roleAssignmentByUserIdCache: { delete(userId: MiUser['id']): void };
};
async function createUser(userData: Partial<MiUser> = {}, profileData: Partial<MiUserProfile> = {}) {
const un = secureRndstr(16);
@@ -87,6 +97,32 @@ describe('UserEntityService', () => {
return user;
}
async function createRole(roleData: Partial<MiRole> = {}) {
const role = await rolesRepository
.insert({
id: genAidx(Date.now()),
updatedAt: new Date(),
lastUsedAt: new Date(),
name: '',
description: '',
...roleData,
})
.then(x => rolesRepository.findOneByOrFail(x.identifiers[0]));
(roleService as unknown as RoleServiceCacheController).rolesCache.delete();
return role;
}
async function assignRole(user: MiUser, role: MiRole) {
await roleAssignmentsRepository.insert({
id: genAidx(Date.now()),
userId: user.id,
roleId: role.id,
});
(roleService as unknown as RoleServiceCacheController).roleAssignmentByUserIdCache.delete(user.id);
}
async function memo(writer: MiUser, target: MiUser, memo: string) {
await userMemosRepository.insert({
id: genAidx(Date.now()),
@@ -196,6 +232,9 @@ describe('UserEntityService', () => {
blockingRepository = app.get<BlockingsRepository>(DI.blockingsRepository);
mutingRepository = app.get<MutingsRepository>(DI.mutingsRepository);
renoteMutingsRepository = app.get<RenoteMutingsRepository>(DI.renoteMutingsRepository);
rolesRepository = app.get<RolesRepository>(DI.rolesRepository);
roleAssignmentsRepository = app.get<RoleAssignmentsRepository>(DI.roleAssignmentsRepository);
roleService = app.get<RoleService>(RoleService);
});
afterAll(async () => {
@@ -249,6 +288,60 @@ describe('UserEntityService', () => {
expect(actual.achievements).toEqual(achievements);
});
test('MeDetailed hiddenRoleIds are sanitized to assigned public non-forced roles in stored order', async() => {
const me = await createUser();
const visibleRole = await createRole({ name: 'visible', isPublic: true });
const secondVisibleRole = await createRole({ name: 'visible2', isPublic: true });
const privateRole = await createRole({ name: 'private', isPublic: false });
const forcedRole = await createRole({ name: 'forced', isPublic: true, isPublicDisplayRequired: true });
const unassignedRole = await createRole({ name: 'unassigned', isPublic: true });
await assignRole(me, visibleRole);
await assignRole(me, secondVisibleRole);
await assignRole(me, privateRole);
await assignRole(me, forcedRole);
await usersRepository.update(me.id, {
hiddenRoleIds: [
secondVisibleRole.id,
'unknownroleid',
unassignedRole.id,
privateRole.id,
forcedRole.id,
visibleRole.id,
secondVisibleRole.id,
],
});
const updatedMe = await usersRepository.findOneByOrFail({ id: me.id });
const actual = await service.pack(updatedMe, updatedMe, { schema: 'MeDetailed' });
expect(actual.hiddenRoleIds).toEqual([secondVisibleRole.id, visibleRole.id]);
});
test('UserDetailed filters hidden display roles for normal viewers, preserves forced roles, and bypasses for moderators', async() => {
const [viewer, moderator, target] = await Promise.all([createUser(), createUser(), createUser()]);
const hiddenRole = await createRole({ name: 'hidden', isPublic: true, asBadge: true, displayOrder: 30 });
const visibleRole = await createRole({ name: 'visible', isPublic: true, asBadge: true, displayOrder: 20 });
const forcedRole = await createRole({ name: 'forced', isPublic: true, isPublicDisplayRequired: true, asBadge: true, displayOrder: 10 });
const moderatorRole = await createRole({ name: 'moderator', isModerator: true });
await assignRole(target, hiddenRole);
await assignRole(target, visibleRole);
await assignRole(target, forcedRole);
await assignRole(moderator, moderatorRole);
await usersRepository.update(target.id, { hiddenRoleIds: [hiddenRole.id, forcedRole.id] });
const updatedTarget = await usersRepository.findOneByOrFail({ id: target.id });
const normalView = await service.pack(updatedTarget, viewer, { schema: 'UserDetailed' });
const moderatorView = await service.pack(updatedTarget, moderator, { schema: 'UserDetailed' });
expect(normalView.roles.map(role => role.id)).toEqual([visibleRole.id, forcedRole.id]);
expect(normalView.badgeRoles?.map(role => role.id)).toEqual([visibleRole.id, forcedRole.id]);
expect(normalView.badgeRoles?.every(role => typeof role.id === 'string')).toBe(true);
expect(moderatorView.roles.map(role => role.id)).toEqual([hiddenRole.id, visibleRole.id, forcedRole.id]);
expect(moderatorView.badgeRoles?.map(role => role.id)).toEqual([hiddenRole.id, visibleRole.id, forcedRole.id]);
});
test('alsoKnownAs as string does not throw', async () => {
const me = await createUser();
const who = await createUser();

View File

@@ -9,10 +9,10 @@ import { basename, isAbsolute } from 'node:path';
import { randomUUID } from 'node:crypto';
import { inspect } from 'node:util';
import WebSocket, { ClientOptions } from 'ws';
import fetch, { RequestInit, type Headers } from 'node-fetch';
import fetch, { Blob, FormData } from 'node-fetch';
import type { RequestInit, Headers, Response } from 'node-fetch';
import * as htmlParser from 'node-html-parser';
import { DataSource } from 'typeorm';
import { type Response } from 'node-fetch';
import Fastify from 'fastify';
import { entities } from '@/postgres.js';
import { loadConfig } from '@/config.js';

View File

@@ -11,7 +11,7 @@
},
"devDependencies": {
"@types/estree": "1.0.9",
"@types/node": "24.13.2",
"@types/node": "26.0.0",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"rollup": "4.62.2"

View File

@@ -34,7 +34,7 @@
"@testing-library/vue": "8.1.0",
"@types/estree": "1.0.9",
"@types/micromatch": "4.0.10",
"@types/node": "24.13.2",
"@types/node": "26.0.0",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/tinycolor2": "1.4.6",
"@types/ws": "8.18.1",

View File

@@ -8,7 +8,7 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"devDependencies": {
"@types/node": "24.13.2",
"@types/node": "26.0.0",
"@types/tinycolor2": "1.4.6",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",

View File

@@ -357,6 +357,7 @@ export function role(params: {
isPublic?: boolean,
isExplorable?: boolean,
asBadge?: boolean,
isPublicDisplayRequired?: boolean,
canEditMembersByModerator?: boolean,
usersCount?: number,
}, seed?: string): entities.Role {
@@ -380,6 +381,7 @@ export function role(params: {
isPublic: params.isPublic ?? true,
isExplorable: params.isExplorable ?? true,
asBadge: params.asBadge ?? true,
isPublicDisplayRequired: params.isPublicDisplayRequired ?? false,
canEditMembersByModerator: params.canEditMembersByModerator ?? false,
usersCount: params.usersCount ?? 10,
preserveAssignmentOnMoveAccount: false,

View File

@@ -99,7 +99,7 @@
"@types/insert-text-at-cursor": "0.3.2",
"@types/matter-js": "0.20.2",
"@types/micromatch": "4.0.10",
"@types/node": "24.13.2",
"@types/node": "26.0.0",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/sanitize-html": "2.16.1",
"@types/seedrandom": "3.0.8",

View File

@@ -13,8 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkA>
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
<div :class="$style.username"><MkAcct :user="note.user"/></div>
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
<img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
<div v-if="badgeRoles" :class="$style.badgeRoles">
<img v-for="role in badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
</div>
<div :class="$style.info">
<div v-if="mock">
@@ -35,17 +35,22 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { inject } from 'vue';
import { computed, inject } from 'vue';
import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js';
import { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js';
import { DI } from '@/di.js';
defineProps<{
const props = defineProps<{
note: Misskey.entities.Note;
}>();
type BadgeRoleWithId = NonNullable<Misskey.entities.Note['user']['badgeRoles']>[number] & {
id: string;
};
const badgeRoles = computed(() => props.note.user.badgeRoles as BadgeRoleWithId[] | undefined);
const mock = inject(DI.mock, false);
</script>

View File

@@ -37,7 +37,7 @@ const props = defineProps<{
id?: string;
}>();
type RoleLike = Pick<Misskey.entities.Role, 'name' | 'description' | 'isAdministrator' | 'isModerator' | 'color' | 'iconUrl' | 'target' | 'isPublic' | 'isExplorable' | 'asBadge' | 'canEditMembersByModerator' | 'displayOrder' | 'preserveAssignmentOnMoveAccount'> & {
type RoleLike = Pick<Misskey.entities.Role, 'name' | 'description' | 'isAdministrator' | 'isModerator' | 'color' | 'iconUrl' | 'target' | 'isPublic' | 'isPublicDisplayRequired' | 'isExplorable' | 'asBadge' | 'canEditMembersByModerator' | 'displayOrder' | 'preserveAssignmentOnMoveAccount'> & {
condFormula: any;
policies: any;
};
@@ -62,6 +62,7 @@ if (props.id) {
target: 'manual',
condFormula: { id: genId(), type: 'isRemote' },
isPublic: false,
isPublicDisplayRequired: false,
isExplorable: false,
asBadge: false,
canEditMembersByModerator: false,

View File

@@ -62,6 +62,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts._role.descriptionOfIsPublic }}</template>
</MkSwitch>
<MkSwitch v-if="role.isPublic" v-model="role.isPublicDisplayRequired" :readonly="readonly">
<template #label>{{ i18n.ts._role.isPublicDisplayRequired }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfIsPublicDisplayRequired }}</template>
</MkSwitch>
<MkSwitch v-model="role.asBadge" :readonly="readonly">
<template #label>{{ i18n.ts._role.asBadge }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfAsBadge }}</template>
@@ -110,7 +115,7 @@ import { instance } from '@/instance.js';
import { deepClone } from '@/utility/clone.js';
import type { PolicyMeta } from './roles.policy-editor.vue';
type RoleLike = Pick<Misskey.entities.Role, 'name' | 'description' | 'isAdministrator' | 'isModerator' | 'color' | 'iconUrl' | 'target' | 'isPublic' | 'isExplorable' | 'asBadge' | 'canEditMembersByModerator' | 'displayOrder' | 'preserveAssignmentOnMoveAccount'> & {
type RoleLike = Pick<Misskey.entities.Role, 'name' | 'description' | 'isAdministrator' | 'isModerator' | 'color' | 'iconUrl' | 'target' | 'isPublic' | 'isPublicDisplayRequired' | 'isExplorable' | 'asBadge' | 'canEditMembersByModerator' | 'displayOrder' | 'preserveAssignmentOnMoveAccount'> & {
id?: Misskey.entities.Role['id'] | null;
condFormula: any;
policies: any;
@@ -200,6 +205,7 @@ const save = throttle(100, () => {
isAdministrator: role.value.isAdministrator,
isModerator: role.value.isModerator,
isPublic: role.value.isPublic,
isPublicDisplayRequired: role.value.isPublicDisplayRequired,
isExplorable: role.value.isExplorable,
asBadge: role.value.asBadge,
canEditMembersByModerator: role.value.canEditMembersByModerator,

View File

@@ -93,6 +93,11 @@ const menuDef = computed<SuperMenuDef[]>(() => [{
text: i18n.ts.privacy,
to: '/settings/privacy',
active: currentPage.value?.route.name === 'privacy',
}, {
icon: 'ti ti-badges',
text: i18n.ts.roleSettings,
to: '/settings/roles',
active: currentPage.value?.route.name === 'roles',
}, {
icon: 'ti ti-bell',
text: i18n.ts.notifications,

View File

@@ -49,17 +49,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['roles']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-badges"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.rolesAssignedToMe }}</SearchLabel></template>
<div class="_gaps_s">
<MkRolePreview v-for="role in $i.roles" :key="role.id" :role="role" :forModeration="false"/>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['account', 'move', 'migration']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-plane"></i></SearchIcon></template>
@@ -166,7 +155,6 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import FormSection from '@/components/form/section.vue';
import { prefer } from '@/preferences.js';
import MkRolePreview from '@/components/MkRolePreview.vue';
import { signout } from '@/signout.js';
import { hideAllTips as _hideAllTips, resetAllTips as _resetAllTips } from '@/tips.js';
import { suggestReload } from '@/utility/reload-suggest.js';

View File

@@ -0,0 +1,100 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<SearchMarker path="/settings/roles" :label="i18n.ts.roleSettings" :keywords="['role', 'badge']" icon="ti ti-badges">
<div class="_gaps_m">
<SearchMarker :keywords="['roles']">
<div class="_gaps_s">
<MkInfo v-if="roleDisplayRoles.length === 0">{{ i18n.ts._roleDisplay.noRoles }}</MkInfo>
<div v-for="role in roleDisplayRoles" :key="role.id" :class="$style.roleItem">
<MkRolePreview :role="role" :forModeration="false"/>
<MkSwitch
:modelValue="isRoleDisplayShown(role)"
:disabled="role.isPublicDisplayRequired"
@update:modelValue="value => updateRoleDisplay(role, value)"
>
<template #label>{{ i18n.ts._roleDisplay.title }}</template>
<template v-if="role.isPublicDisplayRequired" #caption>{{ i18n.ts._roleDisplay.alwaysShownByAdmin }}</template>
</MkSwitch>
</div>
</div>
</SearchMarker>
</div>
</SearchMarker>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import type * as Misskey from 'misskey-js';
import MkSwitch from '@/components/MkSwitch.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkRolePreview from '@/components/MkRolePreview.vue';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { ensureSignin } from '@/i.js';
import { definePage } from '@/page.js';
import { updateCurrentAccount } from '@/accounts.js';
const $i = ensureSignin();
type RoleDisplayRole = Misskey.entities.IResponse['roles'][number] & {
isPublicDisplayRequired?: boolean;
};
type MeDetailedWithRoleDisplay = Misskey.entities.MeDetailed & {
hiddenRoleIds?: string[];
roles: RoleDisplayRole[];
};
type IUpdateWithHiddenRoleIdsRequest = Misskey.Endpoints['i/update']['req'] & {
hiddenRoleIds: string[];
};
const me = $i as MeDetailedWithRoleDisplay;
const hiddenRoleIds = ref([...getHiddenRoleIds(me)]);
const roleDisplayRoles = computed(() => me.roles);
function getHiddenRoleIds(user: { hiddenRoleIds?: string[] }): string[] {
return user.hiddenRoleIds ?? [];
}
function isRoleDisplayShown(role: RoleDisplayRole): boolean {
return role.isPublicDisplayRequired === true || !hiddenRoleIds.value.includes(role.id);
}
async function updateRoleDisplay(role: RoleDisplayRole, visible: boolean) {
if (role.isPublicDisplayRequired === true) return;
const nextHiddenRoleIds = new Set(hiddenRoleIds.value);
if (visible) {
nextHiddenRoleIds.delete(role.id);
} else {
nextHiddenRoleIds.add(role.id);
}
const nextIds = roleDisplayRoles.value
.filter(role => role.isPublicDisplayRequired !== true && nextHiddenRoleIds.has(role.id))
.map(role => role.id);
const updated = await misskeyApi<MeDetailedWithRoleDisplay, 'i/update', IUpdateWithHiddenRoleIdsRequest>('i/update', {
hiddenRoleIds: nextIds,
});
updateCurrentAccount(updated);
hiddenRoleIds.value = [...getHiddenRoleIds(updated)];
}
definePage(() => ({
title: i18n.ts.roleSettings,
icon: 'ti ti-badges',
}));
</script>
<style lang="scss" module>
.roleItem {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>

View File

@@ -3,32 +3,82 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import type { StoryObj } from '@storybook/vue3';
import { expect, waitFor, within } from '@storybook/test';
import { HttpResponse, http } from 'msw';
import { userDetailed } from '../../../.storybook/fakes.js';
import { commonHandlers } from '../../../.storybook/mocks.js';
import home_ from './home.vue';
import type { StoryObj } from '@storybook/vue3';
import { $i } from '@/i.js';
type UserDetailed = ReturnType<typeof userDetailed>;
type ProfileRole = UserDetailed['roles'][number] & {
isPublicDisplayRequired?: boolean;
};
type MeRoleDisplay = NonNullable<typeof $i> & {
hiddenRoleIds?: string[];
};
function createProfileRole(id: string, name: string, color: string, isPublicDisplayRequired = false): ProfileRole {
return {
id,
name,
color,
iconUrl: null,
description: `${name} description`,
isModerator: false,
isAdministrator: false,
asBadge: false,
displayOrder: 0,
isPublicDisplayRequired,
};
}
const visibleRole = createProfileRole('role-display-visible', 'Visible Role', '#3b82f6');
const hiddenRole = createProfileRole('role-display-hidden', 'Hidden Role', '#ef4444');
const forcedRole = createProfileRole('role-display-forced', 'Forced Role', '#22c55e', true);
const roleDisplayUser = {
...userDetailed(),
roles: [visibleRole, hiddenRole, forcedRole],
};
function setStoryAccount(user: UserDetailed, hiddenRoleIds: string[]): void {
if ($i == null) return;
Object.assign($i as MeRoleDisplay, {
id: user.id,
username: user.username,
host: user.host,
name: user.name,
hiddenRoleIds,
});
}
function renderHome(args: UserDetailedHomeArgs, hiddenRoleIds: string[] = []) {
return {
components: {
home_,
},
setup() {
setStoryAccount(args.user, hiddenRoleIds);
return {
props: args,
};
},
template: '<home_ v-bind="props" />',
};
}
type UserDetailedHomeArgs = {
user: UserDetailed;
disableNotes?: boolean;
};
export const Default = {
render(args) {
return {
components: {
home_,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<home_ v-bind="props" />',
};
return renderHome(args);
},
args: {
user: userDetailed(),
@@ -79,3 +129,21 @@ export const Default = {
},
},
} satisfies StoryObj<typeof home_>;
export const RoleDisplayVisibility = {
...Default,
render(args) {
return renderHome(args, [hiddenRole.id]);
},
async play({ canvasElement }) {
const canvas = within(canvasElement);
await expect(await canvas.findByText(visibleRole.name)).toBeInTheDocument();
await expect(await canvas.findByText(forcedRole.name)).toBeInTheDocument();
await waitFor(() => expect(canvas.queryByText(hiddenRole.name)).not.toBeInTheDocument());
},
args: {
...Default.args,
user: roleDisplayUser,
},
} satisfies StoryObj<typeof home_>;

View File

@@ -53,8 +53,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user" class="_selectable"/></MkSparkle></div>
</MkFukidashi>
</div>
<div v-if="user.roles.length > 0" class="roles">
<span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color ?? '' }">
<div v-if="visibleProfileRoles.length > 0" class="roles">
<span v-for="role in visibleProfileRoles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color ?? '' }">
<MkA v-adaptive-bg :to="`/roles/${role.id}`">
<img v-if="role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="role.iconUrl"/>
{{ role.name }}
@@ -215,6 +215,13 @@ const props = withDefaults(defineProps<{
disableNotes: false,
});
type ProfileRole = Misskey.entities.UserDetailed['roles'][number] & {
isPublicDisplayRequired?: boolean;
};
type MeDetailedWithRoleDisplay = Misskey.entities.MeDetailed & {
hiddenRoleIds?: string[];
};
const emit = defineEmits<{
(ev: 'showMoreFiles'): void;
}>();
@@ -222,6 +229,13 @@ const emit = defineEmits<{
const router = useRouter();
const user = ref(props.user);
const visibleProfileRoles = computed(() => {
const roles = user.value.roles as ProfileRole[];
if ($i == null || $i.id !== user.value.id) return roles;
const hiddenRoleIds = new Set((($i as MeDetailedWithRoleDisplay).hiddenRoleIds) ?? []);
return roles.filter(role => role.isPublicDisplayRequired === true || !hiddenRoleIds.has(role.id));
});
const narrow = ref<null | boolean>(null);
const rootEl = useTemplateRef('rootEl');
const bannerEl = useTemplateRef('bannerEl');

View File

@@ -88,6 +88,10 @@ export const ROUTE_DEF = [{
path: '/privacy',
name: 'privacy',
component: page(() => import('@/pages/settings/privacy.vue')),
}, {
path: '/roles',
name: 'roles',
component: page(() => import('@/pages/settings/roles.vue')),
}, {
path: '/emoji-palette',
name: 'emoji-palette',

View File

@@ -7,18 +7,46 @@ import { vi } from 'vitest';
import createFetchMock from 'vitest-fetch-mock';
import type { Ref } from 'vue';
import { ref } from 'vue';
// Set i18n
import locales from 'i18n';
import { updateI18n } from '@/i18n.js';
const fetchMocker = createFetchMock(vi);
fetchMocker.enableMocks();
updateI18n(locales['en-US']);
// XXX: misskey-js panics if WebSocket is not defined
vi.stubGlobal('WebSocket', class WebSocket extends EventTarget { static CLOSING = 2; });
// XXX: localStorageがない場合がある
const localStorageMock = (() => {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.get(key) ?? null;
},
setItem(key: string, value: string) {
store.set(key, value);
},
removeItem(key: string) {
store.delete(key);
},
clear() {
store.clear();
},
};
})();
vi.stubGlobal('localStorage', localStorageMock);
// 中でlocalStorageを使うので上と順番を変えてはいけない
const { default: locales } = await import('i18n');
fetchMocker.mockIf(/^\/assets\/locales\/.*\.json$/, async () => {
return {
status: 200,
body: JSON.stringify(locales['en-US']),
};
});
const { updateI18n } = await import('@/i18n.js');
updateI18n(locales['en-US']);
export const preferState: Record<string, unknown> = {
// なんかtestがうまいこと動かないのでここに書く

View File

@@ -1,5 +1,4 @@
/// <reference types="vitest/config" />
import path from 'path';
import pluginVue from '@vitejs/plugin-vue';
import pluginGlsl from 'vite-plugin-glsl';
@@ -265,6 +264,7 @@ export function getConfig(): UserConfig {
test: {
environment: 'happy-dom',
setupFiles: ['./test/init.ts'],
deps: {
optimizer: {
web: {

View File

@@ -29,7 +29,7 @@
],
"devDependencies": {
"@types/js-yaml": "4.0.9",
"@types/node": "24.13.2",
"@types/node": "26.0.0",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"chokidar": "5.0.0",

View File

@@ -4404,6 +4404,10 @@ export interface Locale extends ILocale {
* 自分に割り当てられたロール
*/
"rolesAssignedToMe": string;
/**
* ロール設定
*/
"roleSettings": string;
/**
* パスワードリセットしますか?
*/
@@ -8018,6 +8022,14 @@ export interface Locale extends ILocale {
* ユーザーのプロフィールでこのロールが表示されます。
*/
"descriptionOfIsPublic": string;
/**
* 非表示を許可しない(常に表示)
*/
"isPublicDisplayRequired": string;
/**
* 有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。
*/
"descriptionOfIsPublicDisplayRequired": string;
/**
* オプション
*/
@@ -12171,6 +12183,24 @@ export interface Locale extends ILocale {
"notUnique": string;
};
};
"_roleDisplay": {
/**
* 表示するロール/ロールバッジ
*/
"title": string;
/**
* 自分のプロフィールやノートに表示する公開ロールを選択します。
*/
"description": string;
/**
* 管理者により常に表示するよう設定されています。
*/
"alwaysShownByAdmin": string;
/**
* 表示できる公開ロールはありません。
*/
"noRoles": string;
};
"_roleSelectDialog": {
/**
* 選択されていません

View File

@@ -11,7 +11,7 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"devDependencies": {
"@types/node": "24.13.2",
"@types/node": "26.0.0",
"@types/wawoff2": "1.0.2",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1"

View File

@@ -25,7 +25,7 @@
},
"devDependencies": {
"@types/matter-js": "0.20.2",
"@types/node": "24.13.2",
"@types/node": "26.0.0",
"@types/seedrandom": "3.0.8",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",

View File

@@ -8,7 +8,7 @@
},
"devDependencies": {
"@readme/openapi-parser": "6.1.3",
"@types/node": "24.13.2",
"@types/node": "26.0.0",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"openapi-types": "12.1.3",

View File

@@ -38,7 +38,7 @@
},
"devDependencies": {
"@microsoft/api-extractor": "7.58.9",
"@types/node": "24.13.2",
"@types/node": "26.0.0",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@vitest/coverage-v8": "4.1.9",

View File

@@ -4061,6 +4061,8 @@ export type components = {
/** @enum {string} */
onlineStatus: 'unknown' | 'online' | 'active' | 'offline';
badgeRoles?: {
/** Format: id */
id: string;
name: string;
iconUrl: string | null;
displayOrder: number;
@@ -4151,6 +4153,7 @@ export type components = {
preventAiLearning: boolean;
isExplorable: boolean;
isDeleted: boolean;
hiddenRoleIds: string[];
/** @enum {string} */
twoFactorBackupCodesStock: 'full' | 'partial' | 'none';
hideOnlineStatus: boolean;
@@ -5322,6 +5325,10 @@ export type components = {
isModerator: boolean;
/** @example false */
isAdministrator: boolean;
/** @example false */
asBadge: boolean;
/** @example false */
isPublicDisplayRequired: boolean;
/** @example 0 */
displayOrder: number;
};
@@ -5336,6 +5343,8 @@ export type components = {
/** @example false */
isPublic: boolean;
/** @example false */
isPublicDisplayRequired: boolean;
/** @example false */
isExplorable: boolean;
/** @example false */
asBadge: boolean;
@@ -11040,6 +11049,8 @@ export interface operations {
target: 'manual' | 'conditional';
condFormula: Record<string, never>;
isPublic: boolean;
/** @default false */
isPublicDisplayRequired?: boolean;
isModerator: boolean;
isAdministrator: boolean;
/** @default false */
@@ -11375,6 +11386,7 @@ export interface operations {
target?: 'manual' | 'conditional';
condFormula?: Record<string, never>;
isPublic?: boolean;
isPublicDisplayRequired?: boolean;
isModerator?: boolean;
isAdministrator?: boolean;
isExplorable?: boolean;
@@ -27887,6 +27899,7 @@ export interface operations {
followersVisibility?: 'public' | 'followers' | 'private';
/** @enum {string} */
chatScope?: 'everyone' | 'followers' | 'following' | 'mutual' | 'none';
hiddenRoleIds?: string[];
/** Format: misskey:id */
pinnedPageId?: string | null;
mutedWords?: (string[] | string)[];
@@ -34287,6 +34300,7 @@ export interface operations {
originalNotesCount: number;
usersCount: number;
originalUsersCount: number;
reactionsCount: number;
instances: number;
driveUsageLocal: number;
driveUsageRemote: number;

View File

@@ -24,7 +24,7 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"devDependencies": {
"@types/node": "24.13.2",
"@types/node": "26.0.0",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"esbuild": "0.28.1",

629
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,7 @@ allowBuilds:
'@nestjs/core': true
'@parcel/watcher': true
'@sentry/profiling-node': true
'@sentry-internal/node-cpu-profiler': true
'@sentry/node-cpu-profiler': true
bufferutil: true
canvas: true

View File

@@ -9,7 +9,7 @@
"version": "1.0.0",
"devDependencies": {
"@types/mdast": "4.0.4",
"@types/node": "24.13.2",
"@types/node": "26.0.0",
"@vitest/coverage-v8": "4.1.9",
"mdast-util-to-string": "4.0.0",
"remark": "15.0.1",
@@ -505,13 +505,13 @@
"dev": true
},
"node_modules/@types/node": {
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"version": "26.0.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz",
"integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
"undici-types": "~8.3.0"
}
},
"node_modules/@types/unist": {
@@ -2074,9 +2074,9 @@
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},

View File

@@ -10,7 +10,7 @@
},
"devDependencies": {
"@types/mdast": "4.0.4",
"@types/node": "24.13.2",
"@types/node": "26.0.0",
"@vitest/coverage-v8": "4.1.9",
"mdast-util-to-string": "4.0.0",
"remark": "15.0.1",