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

Compare commits

..

54 Commits

Author SHA1 Message Date
syuilo
5c4778a492 Merge branch 'develop' into feature/role-hidden-setting 2026-07-23 14:58:26 +09:00
syuilo
1cf465ad93 Merge branch 'develop' into feature/role-hidden-setting 2026-07-23 11:12:21 +09:00
syuilo
0ceadf5f44 Merge branch 'develop' into feature/role-hidden-setting 2026-07-22 10:27:05 +09:00
syuilo
b0d1510abc Merge branch 'develop' into feature/role-hidden-setting 2026-07-21 22:44:17 +09:00
syuilo
41b11902a8 Merge branch 'develop' into feature/role-hidden-setting 2026-07-21 11:27:23 +09:00
syuilo
1cdfdfac31 Merge branch 'develop' into feature/role-hidden-setting 2026-07-17 22:09:16 +09:00
kakkokari-gtyih
981d327bf3 fix 2026-07-17 10:14:21 +09:00
kakkokari-gtyih
41cd2eff0c fix: tweak settings 2026-07-17 10:11:52 +09:00
kakkokari-gtyih
1b38954fcd Merge remote-tracking branch 'msky/develop' into feature/role-hidden-setting 2026-07-17 10:03:09 +09:00
syuilo
bd6123ed5c Merge branch 'develop' into feature/role-hidden-setting 2026-07-16 17:40:01 +09:00
syuilo
3923e39810 Merge branch 'develop' into feature/role-hidden-setting 2026-07-16 12:20:46 +09:00
syuilo
1c61d7da51 Merge branch 'develop' into feature/role-hidden-setting 2026-07-15 22:35:03 +09:00
syuilo
a5b858b45b Update CHANGELOG.md 2026-07-15 18:19:46 +09:00
かっこかり
1cdd177478 Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-14 17:07:18 +09:00
syuilo
0623cabc81 Merge branch 'develop' into feature/role-hidden-setting 2026-07-14 14:03:56 +09:00
kakkokari-gtyih
7fbb4828ce fix 2026-07-08 20:14:38 +09:00
syuilo
a4d2273682 Merge branch 'develop' into feature/role-hidden-setting 2026-07-05 14:13:02 +09:00
かっこかり
25090b6bc0 Update CHANGELOG.md 2026-07-05 13:40:58 +09:00
kakkokari-gtyih
4d987e8843 fix test 2026-07-04 15:48:11 +09:00
kakkokari-gtyih
87b88d7347 add comment 2026-07-04 15:29:32 +09:00
kakkokari-gtyih
eaac254498 fix 2026-07-04 15:26:06 +09:00
kakkokari-gtyih
3df1f0e780 ✌️ 2026-07-04 15:21:05 +09:00
kakkokari-gtyih
23ca8b2286 fix 2026-07-04 10:13:11 +09:00
kakkokari-gtyih
f95f5691d3 Merge remote-tracking branch 'msky/develop' into feature/role-hidden-setting 2026-07-04 10:11:34 +09:00
kakkokari-gtyih
eeae7a3c16 fix 2026-07-04 10:09:04 +09:00
kakkokari-gtyih
398f9a6b9b fix 2026-07-03 17:48:10 +09:00
kakkokari-gtyih
28044973e7 fix 2026-07-03 17:23:49 +09:00
kakkokari-gtyih
25aae46c59 fix test 2026-07-03 17:19:29 +09:00
kakkokari-gtyih
9cc4c7d46b fix 2026-07-03 17:11:24 +09:00
kakkokari-gtyih
dfc313cadb refactor 2026-07-03 17:08:06 +09:00
kakkokari-gtyih
17fa6cd13a fix 2026-07-03 16:32:37 +09:00
kakkokari-gtyih
91db5ad709 fix 2026-07-03 16:21:46 +09:00
kakkokari-gtyih
8f35f576e0 🎨 2026-07-03 16:20:30 +09:00
kakkokari-gtyih
fcd0f0c8d2 update migration 2026-07-03 15:18:35 +09:00
kakkokari-gtyih
da35321f28 Merge branch 'develop' into feature/role-hidden-setting 2026-07-03 15:16:41 +09:00
kakkokari-gtyih
640ddb2a58 Update Changelog 2026-07-03 10:12:25 +09:00
kakkokari-gtyih
ab7e9770b1 update migration 2026-07-03 10:08:44 +09:00
kakkokari-gtyih
6f1c91d9c7 Merge branch 'develop' into feature/role-hidden-setting 2026-07-03 10:07:14 +09:00
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
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
51 changed files with 638 additions and 302 deletions

View File

@@ -20,15 +20,9 @@ reviews:
instructions: |
【レビュー対象外】
- フォーマット違反、型エラー、SPDXヘッダーの記載漏れ等、静的解析で検出可能な問題については、別WorkflowCI/CDでチェックされるため、レビュー対象外として無視してください。
【特別な対応を要するレビュー】
- プルリクエストで変更されていない部分において、プルリクエストのタイトルや説明文に照らしてスコープ外であるが修正すべき点を見つけた場合は、このプルリクエストで修正させるのではなく、別のプルリクエストを開いてそれを修正するように指示してください。\
なお、当該プルリクエストで行が追加/削除されたことによりスコープ外の問題が起こりうる場合は、例外的にこのプルリクエストで修正させてください。
【注力してほしい観点】
- ビジネスロジックの不備、セキュリティ、パフォーマンス、設計パターンの適切性などに焦点を当ててレビューしてください。
- プルリクエストのタイトルや説明文に照らしてスコープ外の変更が含まれていないかをレビューしてください。なお、明らかにスコープ外であると確信できるもの(例: 全く関係ないファイルを編集している等)のみを対象とし、スコープ外かどうかの判断に困る場合は指摘しないでください。\
スコープ外の変更が含まれていた場合は指摘し、その部分について、このプルリクエストに含めずに別のプルリクエストを開いて変更するように指示してください。
path_filters:
- "!CHANGELOG.md"
- "!**/__snapshots__/**"

View File

@@ -14,6 +14,9 @@
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。
### General
- Feat: 自分に表示する公開ロールとロールバッジをユーザー側で選べるように
- 必ず表示する必要があるロールを作成することもできます(既存のロールについてはすべて表示必須設定となります。必要に応じて設定を変更してください)
- 今後新規作成するロールのデフォルト値は表示必須設定なし(ユーザーが表示・非表示を選べる)となります。
- Feat: コントロールパネルから二要素認証を解除できるように
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
(Based on https://github.com/MisskeyIO/misskey/pull/214)
@@ -70,7 +73,6 @@
- Fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように
- Fix: フォロワー限定投稿へのリプライをホーム投稿に出来る問題を修正
- Fix: ファイルをアップロードするAPIにて、処理終了後に一時ファイルが削除されないことがある問題を修正
- Fix: 初期設定で作成したアカウント以外でアカウント作成APIが使用できない問題を修正
## 2026.6.0

View File

@@ -2083,6 +2083,8 @@ _role:
isConditionalRole: "これはコンディショナルロールです。"
isPublic: "公開ロール"
descriptionOfIsPublic: "ユーザーのプロフィールでこのロールが表示されます。"
isPublicDisplayRequired: "非表示を許可しない(常に表示)"
descriptionOfIsPublicDisplayRequired: "有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。"
options: "オプション"
policies: "ポリシー"
baseRole: "ベースロール"
@@ -3245,6 +3247,13 @@ _gridComponent:
patternNotMatch: "この値は{pattern}のパターンに一致しません"
notUnique: "この値は一意である必要があります"
_roleDisplay:
title: "ロールの表示/非表示"
description: "自分に割り当てられているロールを確認したり、プロフィールやノート上で表示・公開するロールを選択したりできます。"
roleExplorableAlert: "このロールは、管理者により、{link}への表示とロールタイムラインの有効化が設定されています。プロフィール上で非表示にすることはできますが、あなたにこのロールが付与されていることが知られる可能性があります。"
displayToggle: "ロール/ロールバッジを表示する"
alwaysShownByAdmin: "管理者の設定により非表示にすることはできません。"
_roleSelectDialog:
notSelected: "選択されていません"

View File

@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.7.0-beta.5",
"version": "2026.7.0-beta.3",
"codename": "nasubi",
"repository": {
"type": "git",

View File

@@ -64,7 +64,7 @@ test('throws when filtering leaves fewer than two heap snapshots per side', asyn
})).toThrow('At least two samples per side are required');
});
test('omits a paired-looking PSS delta that stays within observed noise', async () => {
test('reports the difference of medians and leaves a paired-looking PSS delta uncoloured', async () => {
const base = replacePssSamples(await loadFixture('base'), [290_000, 292_900, 295_800, 298_700, 301_600]);
const head = replacePssSamples(await loadFixture('head'), [292_900, 296_300, 298_700, 301_600, 290_000]);
@@ -78,10 +78,16 @@ test('omits a paired-looking PSS delta that stays within observed noise', async
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
});
const row = findMetricRow(markdown, 'PSS');
expect(markdown).not.toContain('| **PSS** |');
expect(markdown).toContain('<small><i>Only metrics showing significant changes are displayed.</i></small>');
expect(markdown).not.toContain('⚠️ **Warning**: Memory usage (PSS)');
expect(row).toContain('295.8 MB <br> ± 2.9 MB');
expect(row).toContain('296.3 MB <br> ± 3.4 MB');
expect(row).toContain('$\\text{+0.5 MB}$');
expect(row).toContain('4.5 MB');
expect(row.split('|')).toHaveLength(7);
expect(row).not.toMatch(/within noise|increase|decrease|inconclusive/);
expect(row).not.toContain('\\color{orange}');
expect(findMetricRow(markdown, 'USS')).not.toContain('\\color{orange}');
});
test('keeps the convergence warning without suppressing table colour or the PSS warning', async () => {

View File

@@ -251,7 +251,7 @@ test('renders a directional encoded byte delta at the absolute threshold', async
const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network');
expect(row).toContain('$\\color{orange}{\\text{+10 KB}}$');
expect(row).toContain('$\\color{orange}{\\text{+10 KB}}$');
});
test('colours absolute and relative deltas together when the row is significant', async () => {
@@ -268,9 +268,9 @@ test('colours absolute and relative deltas together when the row is significant'
const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network');
expect(row).toContain('100 MB <br> ± 0 B');
expect(row).toContain('$\\color{orange}{\\text{+20 KB}}$<br>$\\color{orange}{\\text{+0\\\\%}}$');
expect(row).toContain('| 0 B |');
expect(row).toContain('100 MB <br> ± 0 B');
expect(row).toContain('$\\color{orange}{\\text{+20 KB}}$<br>$\\color{orange}{\\text{+0\\\\%}}$');
expect(row).toContain('| 0 B |');
expect(row.split('|')).toHaveLength(7);
});

View File

@@ -20,15 +20,15 @@ import {
describe('formatBytes', () => {
// 1024ではなく1000区切り。単位が2桁以上なら小数を落とす
test.each([
[0, '0 B'],
[999, '999 B'],
[1_000, '1 KB'],
[1_500, '1.5 KB'],
[15_000, '15 KB'],
[1_234_567, '1.2 MB'],
[12_345_678, '12 MB'],
[1_500_000_000, '1.5 GB'],
[1_500_000_000_000, '1,500 GB'],
[0, '0 B'],
[999, '999 B'],
[1_000, '1 KB'],
[1_500, '1.5 KB'],
[15_000, '15 KB'],
[1_234_567, '1.2 MB'],
[12_345_678, '12 MB'],
[1_500_000_000, '1.5 GB'],
[1_500_000_000_000, '1,500 GB'],
])('formats %i as %s', (input, expected) => {
expect(formatBytes(input)).toBe(expected);
});

View File

@@ -45,7 +45,7 @@ describe('renderHeapSnapshotTable', () => {
report([1_100_000, 1_200_000, 1_300_000]),
));
expect(row).toContain('$\\text{+100 KB}$');
expect(row).toContain('$\\text{+100 KB}$');
expect(row).not.toContain('within noise');
expect(row).not.toContain('\\color{orange}');
});
@@ -56,7 +56,7 @@ describe('renderHeapSnapshotTable', () => {
report([1_200_000, 1_200_000, 1_200_000]),
));
expect(row).toContain('$\\color{orange}{\\text{+200 KB}}$');
expect(row).toContain('$\\color{orange}{\\text{+200 KB}}$');
expect(row).toContain('$\\color{orange}{\\text{+20\\\\%}}$');
expect(row).not.toContain('increase');
});
@@ -67,7 +67,7 @@ describe('renderHeapSnapshotTable', () => {
report([1_050_000, 1_050_000, 1_050_000]),
));
expect(row).toContain('$\\text{+50 KB}$<br>$\\text{+5\\\\%}$');
expect(row).toContain('$\\text{+50 KB}$<br>$\\text{+5\\\\%}$');
expect(row.split('|')[4]).not.toContain('\\color{');
});

View File

@@ -33,7 +33,7 @@ describe('renderMetricComparisonTable', () => {
expect(table.split('\n')).toStrictEqual([
'| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |',
'| <strong>Metric</strong> | 100 units <br> ± 0 units | 120 units <br> ± 0 units | $\\color{orange}{\\text{+20~units}}$<br>$\\color{orange}{\\text{+20\\\\%}}$ | 0 units |',
'| <strong>Metric</strong> | 100 units <br> ± 0 units | 120 units <br> ± 0 units | $\\color{orange}{\\text{+20 units}}$<br>$\\color{orange}{\\text{+20\\\\%}}$ | 0 units |',
]);
});
@@ -52,7 +52,7 @@ describe('renderMetricComparisonTable', () => {
expect(table.split('\n')).toStrictEqual([
'| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |',
'| <strong>Metric</strong> | 100 units | 120 units | $\\color{orange}{\\text{+20~units}}$ | 0 units |',
'| <strong>Metric</strong> | 100 units | 120 units | $\\color{orange}{\\text{+20 units}}$ | 0 units |',
'| | | | | |',
]);
});
@@ -62,7 +62,7 @@ describe('renderMetricComparisonTable', () => {
const head = samples(109, 109, 109);
const table = renderMetricComparisonTable(base, head, [defaultRow]);
expect(table).toContain('$\\text{+9~units}$<br>$\\text{+9\\\\%}$');
expect(table).toContain('$\\text{+9 units}$<br>$\\text{+9\\\\%}$');
expect(table).not.toContain('\\color{');
expect(renderMetricComparisonTable(base, head, [defaultRow], {
onlySignificantChanges: true,
@@ -85,7 +85,7 @@ describe('renderMetricComparisonTable', () => {
{ onlySignificantChanges: true },
);
expect(table).toContain('$\\color{orange}{\\text{+10~units}}$');
expect(table).toContain('$\\color{orange}{\\text{+10 units}}$');
expect(table).toContain('$\\color{orange}{\\text{+10\\\\%}}$');
});
@@ -106,11 +106,11 @@ describe('renderMetricComparisonTable', () => {
[defaultRow],
);
expect(inside).toContain('$\\text{+9~units}$');
expect(inside).toContain('$\\text{+9 units}$');
expect(inside).not.toContain('\\color{');
expect(boundary).toContain('$\\text{+30~units}$');
expect(boundary).toContain('$\\text{+30 units}$');
expect(boundary).not.toContain('\\color{');
expect(outside).toContain('$\\color{orange}{\\text{+31~units}}$');
expect(outside).toContain('$\\color{orange}{\\text{+31 units}}$');
expect(outside).toContain('$\\color{orange}{\\text{+31\\\\%}}$');
});
@@ -121,7 +121,7 @@ describe('renderMetricComparisonTable', () => {
[defaultRow],
);
expect(table).toContain('$\\color{green}{\\text{-20~units}}$');
expect(table).toContain('$\\color{green}{\\text{-20 units}}$');
expect(table).toContain('$\\color{green}{\\text{-16.7\\\\%}}$');
});
@@ -132,7 +132,7 @@ describe('renderMetricComparisonTable', () => {
[defaultRow],
);
expect(table).toContain('$\\color{orange}{\\text{+20~units}}$<br>-');
expect(table).toContain('$\\color{orange}{\\text{+20 units}}$<br>-');
});
test('propagates the minimum sample contract', () => {

View File

@@ -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"`);
}
}

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,
@@ -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)));
}
}

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,
@@ -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;
@@ -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;
@@ -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');
@@ -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,
@@ -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 }))
@@ -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,
@@ -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,
} : {}),
@@ -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, // 後方互換性のため

View File

@@ -5,12 +5,9 @@
import { bindThis } from '@/decorators.js';
import { logManager } from './logging/logging-runtime.js';
import type { LogEntryInput, LogLevel, LoggerContext, LogWriteInput } from './logging/types.js';
import type { LogLevel, LoggerContext, LogWriteInput } from './logging/types.js';
import type { Keyword } from 'color-convert';
// 旧APIのdataは表示用の任意値を受け取り、Errorや配列も既存呼び出しで使用されています。
type LegacyData = Record<string, any> | null;
/**
* ロガー名の階層と従来の公開APIを提供する薄い窓口です。
* 出力条件の判断や整形はLogManagerとLogBackendへ委譲します。
@@ -55,15 +52,6 @@ export default class Logger {
});
}
/** level別メソッドの構造化入力にlevelとLoggerのcontextを付けて渡します。 */
@bindThis
private logStructured(level: LogLevel, input: LogEntryInput): void {
this.write({
...input,
level,
});
}
/** 構造化ログをLoggerのcontext付きでLogManagerへ渡します。 */
@bindThis
public write(input: LogWriteInput): void {
@@ -74,35 +62,24 @@ export default class Logger {
}
/** 処理を継続できない状況を記録します。 */
public error(input: LogEntryInput): void;
public error(error: Error, data?: LegacyData, important?: boolean): void;
public error(message: string, data?: LegacyData, important?: boolean): void;
public error(errorOrMessage: string | Error, data?: LegacyData, important?: boolean): void;
@bindThis
public error(x: LogEntryInput | string | Error, data?: LegacyData, important = false): void {
public error(x: string | Error, data?: Record<string, any> | null, important = false): void {
if (x instanceof Error) {
// エラー本体も第2引数へ残し、従来どおりスタックなどを確認できるようにします。
data = data ?? {};
data.e = x;
this.log('error', x.toString(), data, important, undefined, x);
} else if (typeof x === 'string') {
this.log('error', x, data, important);
} else if (typeof x === 'object') {
this.log('error', `${(x as any).message ?? (x as any).name ?? x}`, data, important);
} else {
this.logStructured('error', x);
this.log('error', `${x}`, data, important);
}
}
/** 処理は継続できるものの、改善が必要な状況を記録します。 */
public warn(input: LogEntryInput): void;
public warn(message: string): void;
public warn(message: string, data?: LegacyData, important?: boolean): void;
@bindThis
public warn(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void {
if (typeof inputOrMessage === 'string') {
this.log('warn', inputOrMessage, data, important);
} else {
this.logStructured('warn', inputOrMessage);
}
public warn(message: string, data?: Record<string, any> | null, important = false): void {
this.log('warn', message, data, important);
}
/** 処理が成功したことを、従来のDONE表示で記録します。 */
@@ -112,40 +89,14 @@ export default class Logger {
}
/** 開発者向けの調査情報を記録します。 */
public debug(input: LogEntryInput): void;
public debug(message: string): void;
public debug(message: string, data?: LegacyData, important?: boolean): void;
@bindThis
public debug(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void {
if (typeof inputOrMessage === 'string') {
this.log('debug', inputOrMessage, data, important);
} else {
this.logStructured('debug', inputOrMessage);
}
public debug(message: string, data?: Record<string, any> | null, important = false): void {
this.log('debug', message, data, important);
}
/** 通常の動作状況を記録します。 */
public info(input: LogEntryInput): void;
public info(message: string): void;
public info(message: string, data?: LegacyData, important?: boolean): void;
@bindThis
public info(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void {
if (typeof inputOrMessage === 'string') {
this.log('info', inputOrMessage, data, important);
} else {
this.logStructured('info', inputOrMessage);
}
}
/** 致命的な状況を構造化ログとして記録します。 */
public fatal(input: LogEntryInput): void;
public fatal(message: string): void;
@bindThis
public fatal(inputOrMessage: LogEntryInput | string): void {
if (typeof inputOrMessage === 'string') {
this.logStructured('fatal', { message: inputOrMessage });
} else {
this.logStructured('fatal', inputOrMessage);
}
public info(message: string, data?: Record<string, any> | null, important = false): void {
this.log('info', message, data, important);
}
}

View File

@@ -52,19 +52,15 @@ export type LogTraceContext = {
/** LogManagerが出力直前に呼び出すTrace Context取得処理です。 */
export type LogTraceContextProvider = () => LogTraceContext | undefined;
/** level別のLoggerメソッドが受け取る構造化ログの共通入力です。 */
export type LogEntryInput = {
/** ロガーの呼び出し側が構造化ログとして指定する入力です。 */
export type LogWriteInput = {
readonly level: LogLevel;
readonly message: string;
readonly eventName?: string;
readonly attributes?: Readonly<Record<string, unknown>>;
readonly error?: unknown;
};
/** 動的なlevelを含めてLogManagerへ渡す構造化ログの入力です。 */
export type LogWriteInput = LogEntryInput & {
readonly level: LogLevel;
};
/**
* ロガー名を構成する一要素です。
* 色は見やすい形式での表示だけに使い、ログの意味には影響させません。

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,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,
@@ -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,

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

@@ -7,7 +7,6 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { MiMeta, UsersRepository } from '@/models/_.js';
import { SignupService } from '@/core/SignupService.js';
import { RoleService } from '@/core/RoleService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { localUsernameSchema, passwordSchema } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
@@ -78,7 +77,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userEntityService: UserEntityService,
private signupService: SignupService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, _me, token) => {
const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null;
@@ -95,7 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// 初期パスワードが設定されていないのに初期パスワードが入力された場合
throw new ApiError(meta.errors.wrongInitialPassword);
}
} else if (token !== null || !(await this.roleService.isAdministrator(me))) {
} else if ((this.serverSettings.rootUserId != null && (this.serverSettings.rootUserId !== me?.id)) || token !== null) {
// 初回セットアップではなく、管理者でない場合 or 外部トークンを使用している場合
throw new ApiError(meta.errors.accessDenied);
}

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

@@ -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,16 @@ describe('ユーザー', () => {
description: rolePublic.description,
isModerator: rolePublic.isModerator,
isAdministrator: rolePublic.isAdministrator,
asBadge: rolePublic.asBadge,
isPublicDisplayRequired: rolePublic.isPublicDisplayRequired,
isExplorable: rolePublic.isExplorable,
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 +683,42 @@ describe('ユーザー', () => {
description: roleBadge.description,
isModerator: roleBadge.isModerator,
isAdministrator: roleBadge.isAdministrator,
asBadge: roleBadge.asBadge,
isPublicDisplayRequired: roleBadge.isPublicDisplayRequired,
isExplorable: roleBadge.isExplorable,
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,
@@ -27,6 +30,7 @@ import { PageEntityService } from '@/core/entities/PageEntityService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { RoleService } from '@/core/RoleService.js';
import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { IdService } from '@/core/IdService.js';
import { UtilityService } from '@/core/UtilityService.js';
@@ -67,6 +71,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 +98,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()),
@@ -146,6 +183,7 @@ describe('UserEntityService', () => {
ApPersonService,
NoteEntityService,
PageEntityService,
RoleEntityService,
CustomEmojiService,
AnnouncementService,
RoleService,
@@ -196,6 +234,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 +290,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

@@ -76,91 +76,6 @@ describe('Logger', () => {
}));
});
test('supports structured input through every level-specific method', () => {
const logger = new Logger('root').createSubLogger('child');
const error = new Error('broken');
const input = {
eventName: 'example.failed',
message: 'failed',
attributes: { id: 'id' },
error,
};
logger.debug(input);
logger.info(input);
logger.warn(input);
logger.error(input);
logger.fatal(input);
expect(mocks.write.mock.calls.map(([entry]) => entry.level)).toEqual([
'debug',
'info',
'warn',
'error',
'fatal',
]);
for (const [entry] of mocks.write.mock.calls) {
expect(entry).toMatchObject({
...input,
context: [
{ name: 'root', color: undefined },
{ name: 'child', color: undefined },
],
});
expect(entry).not.toHaveProperty('compatibility');
}
});
test('level-specific methods own the level even for runtime-invalid input', () => {
const logger = new Logger('root');
// @ts-expect-error level is selected by the method rather than the input object
logger.warn({ level: 'error', message: 'warning' });
expect(mocks.write.mock.calls[0][0]).toMatchObject({
level: 'warn',
message: 'warning',
});
});
test('preserves the legacy string signatures for non-error levels', () => {
const logger = new Logger('root');
logger.debug('debug', { source: 'debug' }, true);
logger.info('info', null, true);
logger.warn('warn', { source: 'warn' });
expect(mocks.write.mock.calls.map(([entry]) => entry.compatibility)).toEqual([
{ legacyLevel: undefined, important: true, data: { source: 'debug' } },
{ legacyLevel: undefined, important: true, data: null },
{ legacyLevel: undefined, important: false, data: { source: 'warn' } },
]);
});
test('records a fatal string through the structured API', () => {
new Logger('root').fatal('fatal message');
expect(mocks.write).toHaveBeenCalledWith({
level: 'fatal',
message: 'fatal message',
context: [{ name: 'root', color: undefined }],
});
});
test('preserves the legacy error string signature', () => {
new Logger('root').error('failed', { requestId: 'request' }, true);
expect(mocks.write).toHaveBeenCalledWith({
level: 'error',
message: 'failed',
context: [{ name: 'root', color: undefined }],
compatibility: {
legacyLevel: undefined,
important: true,
data: { requestId: 'request' },
},
});
});
test('uses Error.toString and adds the Error to existing data', () => {
const logger = new Logger('root');
const error = new TypeError('broken');

View File

@@ -13,3 +13,8 @@ declare const _PERF_PREFIX_: string;
// for dev-mode
declare const _LANGS_FULL_: string[][];
// TagCanvas
interface Window {
TagCanvas: any;
}

View File

@@ -14,3 +14,9 @@ declare const _PERF_PREFIX_: string;
// for dev-mode
declare const _LANGS_FULL_: string[][];
// TagCanvas
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TagCanvas: any;
}

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

@@ -13,3 +13,8 @@ declare const _PERF_PREFIX_: string;
// for dev-mode
declare const _LANGS_FULL_: string[][];
// TagCanvas
interface Window {
TagCanvas: any;
}

File diff suppressed because one or more lines are too long

View File

@@ -22,7 +22,6 @@
"@mcaptcha/core-glue": "0.1.0-alpha-5",
"@misskey-dev/browser-image-resizer": "2024.1.0",
"@misskey-dev/emoji-data": "17.0.3",
"@misskey-dev/tagcanvas-es": "0.1.2",
"@sentry/vue": "10.65.0",
"@simplewebauthn/browser": "13.3.0",
"@syuilo/aiscript": "1.2.1",

View File

@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@pointerup.passive="onPointerup"
@pointercancel.passive="cancelPointerGesture"
@touchstart.passive="onTouchstart"
@touchmove="onTouchmove"
@touchmove.passive="onTouchmove"
@touchcancel.passive="cancelPointerGesture"
@contextmenu="cancelPointerGesture"
@wheel="onWheel"
@@ -284,7 +284,7 @@ function shouldHideInGallery(content: Content): boolean {
if (!hiddenByDefault) return false;
// ギャラリー起動時に最初に開いたセンシティブ画像だけは初期表示で隠さない
if (content.file.isSensitive && props.initiallyOpened) {
if (content.file.isSensitive && prefer.s.nsfw !== 'force' && props.initiallyOpened) {
return false;
}
@@ -721,13 +721,6 @@ function onTouchstart(ev: TouchEvent) {
function onTouchmove(ev: TouchEvent) {
doubleTapDetector.onTouchmove(ev);
// スワイプ操作中は、ブラウザがタッチを慣性スクロールとして認識するのを防ぐためにpreventDefaultする必要がある
// touch-action: noneが指定されているが、それだけではブラウザによっては慣性スクロールが発生した扱いになり、
// その後のタップが慣性スクロールを止めるためのタップという扱いで握りつぶされてしまうことがあるので必要
if (isVerticalSwiping || isHorizontalSwiping || isZooming.value || pointerEventCache.size > 1) {
ev.preventDefault();
}
}
//#region inertia

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="onClick" @contextmenu.stop="onContextmenu">
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @pointerup="onClick" @contextmenu.stop="onContextmenu">
<component
:is="disableImageLink ? 'div' : 'a'"
v-bind="disableImageLink ? {
@@ -61,8 +61,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="image.comment" :class="$style.indicator">ALT</div>
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--MI_THEME-warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
</div>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true"><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu" @pointerup.stop><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true" @pointerup.stop><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
</template>
</div>
</template>

View File

@@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</button>
<div v-else :class="$style.videoRoot" @click="emit('mediaClick', $event)">
<div v-else :class="$style.videoRoot" @pointerup="emit('mediaClick', $event)">
<img
v-if="video.thumbnailUrl"
:class="$style.video"
@@ -41,8 +41,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-player-play"></i>
</div>
</div>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true"><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu" @pointerup.stop><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true" @pointerup.stop><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
</div>
</div>
</template>

View File

@@ -69,8 +69,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.noteHeaderUsername">
<MkAcct :user="appearNote.user"/>
</div>
<div v-if="appearNote.user.badgeRoles" :class="$style.noteHeaderBadgeRoles">
<img v-for="(role, i) in appearNote.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
<div v-if="badgeRoles" :class="$style.noteHeaderBadgeRoles">
<img v-for="role in badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
</div>
</div>
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
@@ -251,6 +251,7 @@ import { Paginator } from '@/utility/paginator.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import number from '@/filters/number.js';
import { DI } from '@/di.js';
import { $i } from '@/i.js';
import type { Keymap } from '@/utility/hotkey.js';
// コンポーネント外部の依存関係
@@ -332,6 +333,14 @@ provide(DI.mfmEmojiReactCallback, reactViaMfmEmoji);
// MkNoteDetailed固有
const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null);
const badgeRoles = computed(() => {
const roles = appearNote.user.badgeRoles;
if (roles == null || $i == null || $i.id !== appearNote.userId) return roles;
// 自分のノートを自分で見た場合レスポンスに非表示ロールも含まれるので、別途除外する必要がある
const hiddenRoleIds = new Set($i.hiddenRoleIds ?? []);
return roles.filter(role => !hiddenRoleIds.has(role.id));
});
const renotesPaginator = markRaw(new Paginator('notes/renotes', {
limit: 10,

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,26 @@ 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 { $i } from '@/i.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;
}>();
const badgeRoles = computed(() => {
const roles = props.note.user.badgeRoles;
if (roles == null || $i == null || $i.id !== props.note.userId) return roles;
// 自分のプロフィールを自分で見た場合レスポンスに非表示ロールも含まれるので、別途除外する必要がある
const hiddenRoleIds = new Set(($i.hiddenRoleIds) ?? []);
return roles.filter(role => !hiddenRoleIds.has(role.id));
});
const mock = inject(DI.mock, false);
</script>

View File

@@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div ref="rootEl" :class="$style.root">
<canvas ref="canvasEl" style="display: block;" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas>
<div ref="tagsEl" :class="$style.tags">
<canvas :id="idForCanvas" ref="canvasEl" style="display: block;" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas>
<div :id="idForTags" ref="tagsEl" :class="$style.tags">
<ul>
<slot></slot>
</ul>
@@ -15,68 +15,62 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onMounted, onBeforeUnmount, nextTick, ref, shallowRef, useTemplateRef } from 'vue';
import { onMounted, watch, onBeforeUnmount, ref, useTemplateRef } from 'vue';
import { themeManager } from '@/theme.js';
import tinycolor from 'tinycolor2';
import { TagCanvas } from '@misskey-dev/tagcanvas-es';
const loaded = !!window.TagCanvas;
const SAFE_FOR_HTML_ID = 'abcdefghijklmnopqrstuvwxyz';
const idForCanvas = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
const idForTags = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
const available = ref(false);
const rootEl = useTemplateRef('rootEl');
const canvasEl = useTemplateRef('canvasEl');
const tagsEl = useTemplateRef('tagsEl');
const tagCanvas = shallowRef<TagCanvas | null>(null);
const width = ref(300);
function createTagCanvas() {
if (tagCanvas.value) {
tagCanvas.value.destroy();
tagCanvas.value = null;
}
if (tagsEl.value == null || canvasEl.value == null) return;
if (tagsEl.value.children[0].children.length === 0) return;
tagCanvas.value = new TagCanvas(canvasEl.value, {
tagContainer: tagsEl.value,
textColor: '#ffffff',
outlineColor: tinycolor(themeManager.currentCompiledTheme!.accent).toHexString(),
outlineRadius: 10,
initial: [-0.030, -0.010],
frontSelect: true,
imageRadius: 8,
// dragControl: true,
dragThreshold: 3,
wheelZoom: false,
reverse: true,
depth: 0.5,
maxSpeed: 0.2,
minSpeed: 0.003,
stretchX: 0.8,
stretchY: 0.8,
});
}
watch(available, () => {
try {
window.TagCanvas.Start(idForCanvas, idForTags, {
textColour: '#ffffff',
outlineColour: tinycolor(themeManager.currentCompiledTheme!.accent).toHexString(),
outlineRadius: 10,
initial: [-0.030, -0.010],
frontSelect: true,
imageRadius: 8,
//dragControl: true,
dragThreshold: 3,
wheelZoom: false,
reverse: true,
depth: 0.5,
maxSpeed: 0.2,
minSpeed: 0.003,
stretchX: 0.8,
stretchY: 0.8,
});
} catch (err) {}
});
onMounted(() => {
if (rootEl.value) width.value = rootEl.value.offsetWidth;
nextTick(() => {
createTagCanvas();
});
if (loaded) {
available.value = true;
} else {
window.document.head.appendChild(Object.assign(window.document.createElement('script'), {
async: true,
src: '/client-assets/tagcanvas.min.js',
})).addEventListener('load', () => available.value = true);
}
});
onBeforeUnmount(() => {
if (tagCanvas.value) {
tagCanvas.value.destroy();
tagCanvas.value = null;
}
if (window.TagCanvas) window.TagCanvas.Delete(idForCanvas);
});
defineExpose({
update: () => {
if (tagCanvas.value) {
tagCanvas.value.update();
} else {
createTagCanvas();
}
window.TagCanvas.Update(idForCanvas);
},
});
</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,7 +62,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts._role.descriptionOfIsPublic }}</template>
</MkSwitch>
<MkSwitch v-model="role.asBadge" :readonly="readonly">
<MkSwitch v-model="role.isPublicDisplayRequired" :disabled="!role.isPublic" :readonly="readonly">
<template #label>{{ i18n.ts._role.isPublicDisplayRequired }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfIsPublicDisplayRequired }}</template>
</MkSwitch>
<MkSwitch v-model="role.asBadge" :disabled="!role.isPublic" :readonly="readonly">
<template #label>{{ i18n.ts._role.asBadge }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfAsBadge }}</template>
</MkSwitch>
@@ -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

@@ -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>
@@ -126,8 +115,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<hr>
<div class="_gaps_s">
<!-- ページ移動したのでリンクを残してあるそのうち消しても良いかも -->
<FormLink to="/settings/roles">
<template #icon><i class="ti ti-badges"></i></template>
{{ i18n.ts.rolesAssignedToMe }}
</FormLink>
<FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink>
<FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink>
</div>
<hr>
@@ -166,7 +162,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

@@ -72,6 +72,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</SearchMarker>
<FormLink to="/settings/roles">
<template #icon><i class="ti ti-badges"></i></template>
<SearchLabel>{{ i18n.ts._roleDisplay.title }}</SearchLabel>
</FormLink>
<SearchMarker :keywords="['chat']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.directMessage }}</SearchLabel></template>
@@ -223,6 +228,7 @@ import { instance } from '@/instance.js';
import { ensureSignin } from '@/i.js';
import { definePage } from '@/page.js';
import FormSlot from '@/components/form/slot.vue';
import FormLink from '@/components/form/link.vue';
import { formatDateTimeString } from '@/utility/format-time-string.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import MkInput from '@/components/MkInput.vue';

View File

@@ -0,0 +1,94 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<SearchMarker path="/settings/roles" :label="i18n.ts.roles" :keywords="['role', 'badge']" icon="ti ti-badges">
<div class="_gaps_m">
<MkFeatureBanner icon="/fluent-emoji/1f3f7.png" color="#ffbf00">
<SearchText>{{ i18n.ts._roleDisplay.description }}</SearchText>
</MkFeatureBanner>
<div class="_gaps_s">
<MkResult v-if="roleDisplayRoles.length === 0" type="empty"/>
<div v-for="role in roleDisplayRoles" :key="role.id" class="_panel _gaps" :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.displayToggle }}</template>
<template v-if="role.isPublicDisplayRequired" #caption>{{ i18n.ts._roleDisplay.alwaysShownByAdmin }}</template>
<template v-else-if="role.isExplorable" #caption>
<i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> <I18n :src="i18n.ts._roleDisplay.roleExplorableAlert">
<template #link>
<MkA class="_link" :to="`/roles/${role.id}`">{{ i18n.ts.explore }}</MkA>
</template>
</I18n>
</template>
</MkSwitch>
</div>
</div>
</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 MkFeatureBanner from '@/components/MkFeatureBanner.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();
const hiddenRoleIds = ref([...getHiddenRoleIds($i)]);
const roleDisplayRoles = computed(() => $i.roles);
function getHiddenRoleIds(user: { hiddenRoleIds?: string[] }): string[] {
return user.hiddenRoleIds ?? [];
}
function isRoleDisplayShown(role: Misskey.entities.RoleLite): boolean {
return role.isPublicDisplayRequired === true || !hiddenRoleIds.value.includes(role.id);
}
async function updateRoleDisplay(role: Misskey.entities.RoleLite, 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('i/update', {
hiddenRoleIds: nextIds,
});
updateCurrentAccount(updated);
hiddenRoleIds.value = [...getHiddenRoleIds(updated)];
}
definePage(() => ({
title: i18n.ts.roles,
icon: 'ti ti-badges',
}));
</script>
<style lang="scss" module>
.roleItem {
padding: 16px;
}
</style>

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 }}
@@ -222,6 +222,14 @@ const emit = defineEmits<{
const router = useRouter();
const user = ref(props.user);
const visibleProfileRoles = computed(() => {
const roles = user.value.roles;
if ($i == null || $i.id !== user.value.id) return roles;
// 自分のプロフィールを自分で見た場合レスポンスに非表示ロールも含まれるので、別途除外する必要がある
const hiddenRoleIds = new Set($i.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

@@ -411,8 +411,7 @@ export class Paginator<
public releaseQueue(): void {
if (this.aheadQueue.length === 0) return; // これやらないと余計なre-renderが走る
for (const item of this.aheadQueue) {
item._shouldAnimateIn_ = false; // 一気に入るときは挿入アニメーションさせない
item._shouldAnimateOut_ = false;
item._shouldAnimateIn_ = true;
}
this.unshiftItems(this.aheadQueue);
this.aheadQueue = [];

View File

@@ -8026,6 +8026,14 @@ export interface Locale extends ILocale {
* ユーザーのプロフィールでこのロールが表示されます。
*/
"descriptionOfIsPublic": string;
/**
* 非表示を許可しない(常に表示)
*/
"isPublicDisplayRequired": string;
/**
* 有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。
*/
"descriptionOfIsPublicDisplayRequired": string;
/**
* オプション
*/
@@ -12179,6 +12187,28 @@ export interface Locale extends ILocale {
"notUnique": string;
};
};
"_roleDisplay": {
/**
* ロールの表示/非表示
*/
"title": string;
/**
* 自分に割り当てられているロールを確認したり、プロフィールやノート上で表示・公開するロールを選択したりできます。
*/
"description": string;
/**
* このロールは、管理者により、{link}への表示とロールタイムラインの有効化が設定されています。プロフィール上で非表示にすることはできますが、あなたにこのロールが付与されていることが知られる可能性があります。
*/
"roleExplorableAlert": ParameterizedString<"link">;
/**
* ロール/ロールバッジを表示する
*/
"displayToggle": string;
/**
* 管理者の設定により非表示にすることはできません。
*/
"alwaysShownByAdmin": string;
};
"_roleSelectDialog": {
/**
* 選択されていません

View File

@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2026.7.0-beta.5",
"version": "2026.7.0-beta.3",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",

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,12 @@ export type components = {
isModerator: boolean;
/** @example false */
isAdministrator: boolean;
/** @example false */
asBadge: boolean;
/** @example false */
isPublicDisplayRequired: boolean;
/** @example false */
isExplorable: boolean;
/** @example 0 */
displayOrder: number;
};
@@ -5336,6 +5345,8 @@ export type components = {
/** @example false */
isPublic: boolean;
/** @example false */
isPublicDisplayRequired: boolean;
/** @example false */
isExplorable: boolean;
/** @example false */
asBadge: boolean;
@@ -11041,6 +11052,8 @@ export interface operations {
target: 'manual' | 'conditional';
condFormula: Record<string, never>;
isPublic: boolean;
/** @default false */
isPublicDisplayRequired?: boolean;
isModerator: boolean;
isAdministrator: boolean;
/** @default false */
@@ -11376,6 +11389,7 @@ export interface operations {
target?: 'manual' | 'conditional';
condFormula?: Record<string, never>;
isPublic?: boolean;
isPublicDisplayRequired?: boolean;
isModerator?: boolean;
isAdministrator?: boolean;
isExplorable?: boolean;
@@ -27889,6 +27903,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)[];

8
pnpm-lock.yaml generated
View File

@@ -673,9 +673,6 @@ importers:
'@misskey-dev/emoji-data':
specifier: 17.0.3
version: 17.0.3
'@misskey-dev/tagcanvas-es':
specifier: 0.1.2
version: 0.1.2
'@sentry/vue':
specifier: 10.65.0
version: 10.65.0(vue@3.5.39(typescript@6.0.2))
@@ -2450,9 +2447,6 @@ packages:
'@misskey-dev/summaly@5.5.1':
resolution: {integrity: sha512-CfpVtmFgltjNS1FAS5+bOluKAF/irZuQnUxAw66ydnp/uesiNNu62ltf9gEgwutT5S+itGtlnP87AOx6DDn/1A==}
'@misskey-dev/tagcanvas-es@0.1.2':
resolution: {integrity: sha512-SGqyu30/CCQyIC95Zcmj3k1vYXwfX66/arsxpl1UUulUqvlRpTpuhB7kEAAps1lqXin0rQzXknb31EeveNKsaw==}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==}
cpu: [arm64]
@@ -10511,8 +10505,6 @@ snapshots:
optionalDependencies:
fastify: 5.8.5
'@misskey-dev/tagcanvas-es@0.1.2': {}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
optional: true