diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 26ef054b9f..845bc38cd1 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2074,6 +2074,8 @@ _role:
isConditionalRole: "これはコンディショナルロールです。"
isPublic: "公開ロール"
descriptionOfIsPublic: "ユーザーのプロフィールでこのロールが表示されます。"
+ isPublicDisplayRequired: "非表示を許可しない(常に表示)"
+ descriptionOfIsPublicDisplayRequired: "有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。"
options: "オプション"
policies: "ポリシー"
baseRole: "ベースロール"
@@ -3224,6 +3226,12 @@ _gridComponent:
patternNotMatch: "この値は{pattern}のパターンに一致しません"
notUnique: "この値は一意である必要があります"
+_roleDisplay:
+ title: "表示するロール/ロールバッジ"
+ description: "自分のプロフィールやノートに表示する公開ロールを選択します。"
+ alwaysShownByAdmin: "管理者により常に表示するよう設定されています。"
+ noRoles: "表示できる公開ロールはありません。"
+
_roleSelectDialog:
notSelected: "選択されていません"
diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts
index 723255267b..f5c5dafa8e 100644
--- a/packages/frontend/.storybook/fakes.ts
+++ b/packages/frontend/.storybook/fakes.ts
@@ -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,
diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue
index ea115c2cd8..7c4f18ae8e 100644
--- a/packages/frontend/src/components/MkNoteHeader.vue
+++ b/packages/frontend/src/components/MkNoteHeader.vue
@@ -13,8 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
bot
-
-
![]()
+
+
@@ -35,17 +35,22 @@ SPDX-License-Identifier: AGPL-3.0-only
diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue
index e806f68162..58e1f65e38 100644
--- a/packages/frontend/src/pages/admin/roles.edit.vue
+++ b/packages/frontend/src/pages/admin/roles.edit.vue
@@ -37,7 +37,7 @@ const props = defineProps<{
id?: string;
}>();
-type RoleLike = Pick
& {
+type RoleLike = Pick & {
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,
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 2a11160ef1..0c6ba8213c 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -62,6 +62,11 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._role.descriptionOfIsPublic }}
+
+ {{ i18n.ts._role.isPublicDisplayRequired }}
+ {{ i18n.ts._role.descriptionOfIsPublicDisplayRequired }}
+
+
{{ i18n.ts._role.asBadge }}
{{ i18n.ts._role.descriptionOfAsBadge }}
@@ -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 & {
+type RoleLike = Pick & {
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,
diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue
index edc71e5156..0d8ac678a9 100644
--- a/packages/frontend/src/pages/settings/privacy.vue
+++ b/packages/frontend/src/pages/settings/privacy.vue
@@ -51,6 +51,41 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
+ {{ i18n.ts._roleDisplay.title }}
+ {{ i18n.ts._roleDisplay.description }}
+
+
+
{{ i18n.ts._roleDisplay.noRoles }}
+
updateRoleDisplay(role, value)"
+ >
+
+
+
+
+
+
+
+
+
+ {{ role.name }}
+
+
+
+
+ {{ i18n.ts._roleDisplay.alwaysShownByAdmin }}
+
+
+
+
+
+
{{ i18n.ts.noCrawle }}
@@ -213,6 +248,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
diff --git a/packages/frontend/src/pages/user/home.stories.impl.ts b/packages/frontend/src/pages/user/home.stories.impl.ts
index 66d3579041..dd86a0e566 100644
--- a/packages/frontend/src/pages/user/home.stories.impl.ts
+++ b/packages/frontend/src/pages/user/home.stories.impl.ts
@@ -2,33 +2,83 @@
* SPDX-FileCopyrightText: syuilo and misskey-project
* 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;
+type ProfileRole = UserDetailed['roles'][number] & {
+ isPublicDisplayRequired?: boolean;
+};
+type MeRoleDisplay = NonNullable & {
+ 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: '',
+ };
+}
+
+type UserDetailedHomeArgs = {
+ user: UserDetailed;
+ disableNotes?: boolean;
+};
+
export const Default = {
render(args) {
- return {
- components: {
- home_,
- },
- setup() {
- return {
- args,
- };
- },
- computed: {
- props() {
- return {
- ...this.args,
- };
- },
- },
- template: '',
- };
+ return renderHome(args);
},
args: {
user: userDetailed(),
@@ -79,3 +129,21 @@ export const Default = {
},
},
} satisfies StoryObj;
+
+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;
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 64b03bc4bc..cdcd577fbb 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -53,8 +53,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-
-
+
+
{{ 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);
const rootEl = useTemplateRef('rootEl');
const bannerEl = useTemplateRef('bannerEl');
diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts
index e13458c22d..aa921aba2c 100644
--- a/packages/i18n/src/autogen/locale.ts
+++ b/packages/i18n/src/autogen/locale.ts
@@ -7990,6 +7990,14 @@ export interface Locale extends ILocale {
* ユーザーのプロフィールでこのロールが表示されます。
*/
"descriptionOfIsPublic": string;
+ /**
+ * 非表示を許可しない(常に表示)
+ */
+ "isPublicDisplayRequired": string;
+ /**
+ * 有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。
+ */
+ "descriptionOfIsPublicDisplayRequired": string;
/**
* オプション
*/
@@ -12095,6 +12103,24 @@ export interface Locale extends ILocale {
"notUnique": string;
};
};
+ "_roleDisplay": {
+ /**
+ * 表示するロール/ロールバッジ
+ */
+ "title": string;
+ /**
+ * 自分のプロフィールやノートに表示する公開ロールを選択します。
+ */
+ "description": string;
+ /**
+ * 管理者により常に表示するよう設定されています。
+ */
+ "alwaysShownByAdmin": string;
+ /**
+ * 表示できる公開ロールはありません。
+ */
+ "noRoles": string;
+ };
"_roleSelectDialog": {
/**
* 選択されていません
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 4b6d26b63f..989c340fed 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -4025,6 +4025,8 @@ export type components = {
/** @enum {string} */
onlineStatus: 'unknown' | 'online' | 'active' | 'offline';
badgeRoles?: {
+ /** Format: id */
+ id: string;
name: string;
iconUrl: string | null;
displayOrder: number;
@@ -4115,6 +4117,7 @@ export type components = {
preventAiLearning: boolean;
isExplorable: boolean;
isDeleted: boolean;
+ hiddenRoleIds: string[];
/** @enum {string} */
twoFactorBackupCodesStock: 'full' | 'partial' | 'none';
hideOnlineStatus: boolean;
@@ -5286,6 +5289,10 @@ export type components = {
isModerator: boolean;
/** @example false */
isAdministrator: boolean;
+ /** @example false */
+ asBadge: boolean;
+ /** @example false */
+ isPublicDisplayRequired: boolean;
/** @example 0 */
displayOrder: number;
};
@@ -5300,6 +5307,8 @@ export type components = {
/** @example false */
isPublic: boolean;
/** @example false */
+ isPublicDisplayRequired: boolean;
+ /** @example false */
isExplorable: boolean;
/** @example false */
asBadge: boolean;
@@ -10874,6 +10883,8 @@ export interface operations {
target: 'manual' | 'conditional';
condFormula: Record;
isPublic: boolean;
+ /** @default false */
+ isPublicDisplayRequired?: boolean;
isModerator: boolean;
isAdministrator: boolean;
/** @default false */
@@ -11209,6 +11220,7 @@ export interface operations {
target?: 'manual' | 'conditional';
condFormula?: Record;
isPublic?: boolean;
+ isPublicDisplayRequired?: boolean;
isModerator?: boolean;
isAdministrator?: boolean;
isExplorable?: boolean;
@@ -27589,6 +27601,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)[];