Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
1960560
feat(backend): ロールの常時表示設定を追加
mattyatea May 20, 2026
f8e28df
feat(backend): 公開ロールの非表示設定に対応
mattyatea May 20, 2026
d0b8940
feat(frontend): ロール表示設定を追加
mattyatea May 20, 2026
4263f62
test: ロール表示設定のテストを追加
mattyatea May 20, 2026
f88e647
docs: ロール表示設定のCHANGELOGを更新
mattyatea May 20, 2026
63a8520
fix(backend): 非表示ロールのバッジを返さないように
mattyatea May 23, 2026
d490936
fix(frontend): 非公開ロールの常時表示設定を隠す
mattyatea May 23, 2026
e57e88e
feat(frontend): ロール設定ページを追加
mattyatea May 23, 2026
9f3d80a
Merge remote-tracking branch 'origin/develop' into feature/role-hidde…
mattyatea May 23, 2026
a4a3606
fixup! 4263f62e7b5a83bd47af4d1afb6667a637b4cc53
mattyatea May 23, 2026
2debf09
Tighten user entity test types
mattyatea Jun 29, 2026
c1bd11e
Merge remote-tracking branch 'origin/develop' into feature/role-hidde…
mattyatea Jun 29, 2026
be9762c
fix: address role display review issues
mattyatea Jun 29, 2026
4cca0c2
chore: remove local agent harness files
mattyatea Jun 29, 2026
e90f664
fix: address PR CI failures
mattyatea Jun 29, 2026
510952f
fix: move changelog entries to latest release
mattyatea Jun 29, 2026
6f1c91d
Merge branch 'develop' into feature/role-hidden-setting
kakkokari-gtyih Jul 3, 2026
ab7e977
update migration
kakkokari-gtyih Jul 3, 2026
640ddb2
Update Changelog
kakkokari-gtyih Jul 3, 2026
da35321
Merge branch 'develop' into feature/role-hidden-setting
kakkokari-gtyih Jul 3, 2026
fcd0f0c
update migration
kakkokari-gtyih Jul 3, 2026
8f35f57
:art:
kakkokari-gtyih Jul 3, 2026
91db5ad
fix
kakkokari-gtyih Jul 3, 2026
17fa6cd
fix
kakkokari-gtyih Jul 3, 2026
dfc313c
refactor
kakkokari-gtyih Jul 3, 2026
9cc4c7d
fix
kakkokari-gtyih Jul 3, 2026
25aae46
fix test
kakkokari-gtyih Jul 3, 2026
2804497
fix
kakkokari-gtyih Jul 3, 2026
398f9a6
fix
kakkokari-gtyih Jul 3, 2026
eeae7a3
fix
kakkokari-gtyih Jul 4, 2026
f95f569
Merge remote-tracking branch 'msky/develop' into feature/role-hidden-…
kakkokari-gtyih Jul 4, 2026
23ca8b2
fix
kakkokari-gtyih Jul 4, 2026
3df1f0e
:v:
kakkokari-gtyih Jul 4, 2026
eaac254
fix
kakkokari-gtyih Jul 4, 2026
87b88d7
add comment
kakkokari-gtyih Jul 4, 2026
4d987e8
fix test
kakkokari-gtyih Jul 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。

### General
- Feat: 公開ロールとロールバッジをユーザー側で個別に非表示にできるように
- Feat: 公開ロールとロールバッジを、上記の設定にかかわらず強制的に表示させることができるように
- 既存のロールについてはすべて強制表示が有効となります。必要に応じて設定を変更してください。
- 今後新規作成するロールのデフォルト値は強制表示なしとなります。
- Feat: コントロールパネルから二要素認証を解除できるように

### Client
Expand Down
8 changes: 8 additions & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2081,6 +2081,8 @@ _role:
isConditionalRole: "これはコンディショナルロールです。"
isPublic: "公開ロール"
descriptionOfIsPublic: "ユーザーのプロフィールでこのロールが表示されます。"
isPublicDisplayRequired: "非表示を許可しない(常に表示)"
descriptionOfIsPublicDisplayRequired: "有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。"
options: "オプション"
policies: "ポリシー"
baseRole: "ベースロール"
Expand Down Expand Up @@ -3243,6 +3245,12 @@ _gridComponent:
patternNotMatch: "この値は{pattern}のパターンに一致しません"
notUnique: "この値は一意である必要があります"

_roleDisplay:
description: "自分に割り当てられているロールを確認したり、プロフィールやノート上で表示・公開するロールを選択したりできます。"
roleExplorableAlert: "このロールは、管理者により、{link}への表示とロールタイムラインの有効化が設定されています。プロフィール上で非表示にすることはできますが、あなたにこのロールが付与されていることが知られる可能性があります。"
displayToggle: "ロール/ロールバッジを表示する"
alwaysShownByAdmin: "管理者の設定により非表示にすることはできません。"

_roleSelectDialog:
notSelected: "選択されていません"

Expand Down
21 changes: 21 additions & 0 deletions packages/backend/migration/1783059479536-RoleDisplayVisibility.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

export class RoleDisplayVisibility1783059479536 {
name = 'RoleDisplayVisibility1783059479536';

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`);

// 既存のロールについてはすべて強制表示とする(新規作成分についてはfalse)
await queryRunner.query(`UPDATE "role" SET "isPublicDisplayRequired" = true`);
}

async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "isPublicDisplayRequired"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "hiddenRoleIds"`);
}
}
1 change: 1 addition & 0 deletions packages/backend/src/core/RoleService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/core/WebhookTestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
makeNotesHiddenBefore: null,
chatScope: 'mutual',
emojis: [],
hiddenRoleIds: [],
score: 0,
host: null,
inbox: null,
Expand Down
30 changes: 29 additions & 1 deletion packages/backend/src/core/entities/RoleEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -83,5 +84,32 @@ export class RoleEntityService {
) {
return Promise.all(roles.map(x => this.pack(x, me)));
}
}

@bindThis
public async packLite(
src: MiRole['id'] | MiRole,
): Promise<Packed<'RoleLite'>> {
const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src });

return {
id: role.id,
name: role.name,
color: role.color,
iconUrl: role.iconUrl,
description: role.description,
isModerator: role.isModerator,
isAdministrator: role.isAdministrator,
asBadge: role.asBadge,
isPublicDisplayRequired: role.isPublicDisplayRequired,
isExplorable: role.isExplorable,
displayOrder: role.displayOrder,
};
}

@bindThis
public packLiteMany(
roles: (MiRole | MiRole['id'])[],
) {
return Promise.all(roles.map(x => this.packLite(x)));
}
}
46 changes: 33 additions & 13 deletions packages/backend/src/core/entities/UserEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -51,6 +52,7 @@ import { ChatService } from '@/core/ChatService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { NoteEntityService } from './NoteEntityService.js';
import type { PageEntityService } from './PageEntityService.js';
import type { RoleEntityService } from './RoleEntityService.js';
import { toArray } from '@/misc/prelude/array.js';

const Ajv = _Ajv.default;
Expand Down Expand Up @@ -88,6 +90,7 @@ export class UserEntityService implements OnModuleInit {
private apPersonService: ApPersonService;
private noteEntityService: NoteEntityService;
private pageEntityService: PageEntityService;
private roleEntityService: RoleEntityService;
private customEmojiService: CustomEmojiService;
private announcementService: AnnouncementService;
private roleService: RoleService;
Expand Down Expand Up @@ -144,6 +147,7 @@ export class UserEntityService implements OnModuleInit {
this.apPersonService = this.moduleRef.get('ApPersonService');
this.noteEntityService = this.moduleRef.get('NoteEntityService');
this.pageEntityService = this.moduleRef.get('PageEntityService');
this.roleEntityService = this.moduleRef.get('RoleEntityService');
this.customEmojiService = this.moduleRef.get('CustomEmojiService');
this.announcementService = this.moduleRef.get('AnnouncementService');
this.roleService = this.moduleRef.get('RoleService');
Expand Down Expand Up @@ -402,6 +406,30 @@ export class UserEntityService implements OnModuleInit {
return `${this.config.url}/users/${userId}`;
}

@bindThis
private prepareRoles<T extends Pick<MiRole, 'id' | 'isPublic' | 'displayOrder' | 'isPublicDisplayRequired'>>(roles: T[], user: MiUser, iAmModerator: boolean, hideByUserPreference = true): T[] {
const hiddenRoleIds = new Set(user.hiddenRoleIds);
return roles.filter(role => (role.isPublic || iAmModerator) && (role.isPublicDisplayRequired || iAmModerator || !hideByUserPreference || !hiddenRoleIds.has(role.id))).sort((a, b) => b.displayOrder - a.displayOrder);
}

@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,
Expand All @@ -425,6 +453,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 }))
Expand Down Expand Up @@ -514,10 +543,9 @@ 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)
.sort((a, b) => b.displayOrder - a.displayOrder)
badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => this.prepareRoles(rs, user, iAmModerator)
.map((r) => ({
id: r.id,
name: r.name,
iconUrl: r.iconUrl,
displayOrder: r.displayOrder,
Expand Down Expand Up @@ -560,16 +588,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 => ({
id: role.id,
name: role.name,
color: role.color,
iconUrl: role.iconUrl,
description: role.description,
isModerator: role.isModerator,
isAdministrator: role.isAdministrator,
displayOrder: role.displayOrder,
}))),
roles: userRoles!.then(roles => this.roleEntityService.packLiteMany(this.prepareRoles(roles, user, iAmModerator, !isMe))),
memo: memo,
moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined,
} : {}),
Expand Down Expand Up @@ -598,6 +617,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, // 後方互換性のため
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/models/Role.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,11 @@ export class MiRole {
})
public isPublic: boolean;

@Column('boolean', {
default: false,
})
public isPublicDisplayRequired: boolean;

// trueの場合ユーザー名の横にバッジとして表示
@Column('boolean', {
default: false,
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -229,6 +230,11 @@ export class MiUser {
})
public emojis: string[];

@Column('varchar', {
length: 32, array: true, default: '{}',
})
public hiddenRoleIds: MiRole['id'][];

// チャットを許可する相手
// everyone: 誰からでも
// followers: フォロワーのみ
Expand Down
20 changes: 20 additions & 0 deletions packages/backend/src/models/json-schema/role.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,21 @@ 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,
},
isExplorable: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
displayOrder: {
type: 'integer',
optional: false, nullable: false,
Expand Down Expand Up @@ -412,6 +427,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,
Expand Down
14 changes: 14 additions & 0 deletions packages/backend/src/models/json-schema/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions packages/backend/src/server/api/endpoints/i/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)[]) => {
Expand Down
Loading
Loading