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 + + + + + @@ -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.noRoles }} + + + + +
+
+
+ @@ -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)[];