1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-25 12:05:19 +02:00

feat(frontend): ロール表示設定を追加

This commit is contained in:
mattyatea
2026-05-21 03:47:05 +09:00
parent f8e28df711
commit d0b8940723
10 changed files with 274 additions and 29 deletions

View File

@@ -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: "選択されていません"

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

View File

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

View File

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

View File

@@ -51,6 +51,41 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</SearchMarker>
<SearchMarker :keywords="['role', 'badge', 'visibility']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts._roleDisplay.title }}</SearchLabel></template>
<template #description><SearchText>{{ i18n.ts._roleDisplay.description }}</SearchText></template>
<div class="_gaps_s">
<MkInfo v-if="roleDisplayRoles.length === 0">{{ i18n.ts._roleDisplay.noRoles }}</MkInfo>
<MkSwitch
v-for="role in roleDisplayRoles"
:key="role.id"
:modelValue="isRoleDisplayShown(role)"
:disabled="role.isPublicDisplayRequired"
@update:modelValue="value => updateRoleDisplay(role, value)"
>
<template #label>
<SearchLabel>
<span :class="$style.roleLabel" :style="{ '--color': role.color ?? '' }">
<span :class="$style.roleIcon">
<img v-if="role.iconUrl" :class="$style.roleBadge" :src="role.iconUrl"/>
<i v-else-if="role.isAdministrator" class="ti ti-crown"></i>
<i v-else-if="role.isModerator" class="ti ti-shield"></i>
<i v-else class="ti ti-user"></i>
</span>
<span>{{ role.name }}</span>
</span>
</SearchLabel>
</template>
<template #caption>
<SearchText v-if="role.isPublicDisplayRequired">{{ i18n.ts._roleDisplay.alwaysShownByAdmin }}</SearchText>
</template>
</MkSwitch>
</div>
</FormSection>
</SearchMarker>
<SearchMarker :keywords="['crawle', 'index', 'search']">
<MkSwitch v-model="noCrawle" @update:modelValue="save()">
<template #label><SearchLabel>{{ i18n.ts.noCrawle }}</SearchLabel></template>
@@ -213,6 +248,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import * as Misskey from 'misskey-js';
import type { MkSelectItem } from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
@@ -230,9 +266,23 @@ import * as os from '@/os.js';
import MkDisableSection from '@/components/MkDisableSection.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
import { updateCurrentAccount } from '@/accounts.js';
const $i = ensureSignin();
type RoleDisplayRole = Misskey.entities.IResponse['roles'][number] & {
isPublicDisplayRequired?: boolean;
};
type MeDetailedWithRoleDisplay = Misskey.entities.MeDetailed & {
hiddenRoleIds?: string[];
roles: RoleDisplayRole[];
};
type IUpdateWithHiddenRoleIdsRequest = Misskey.Endpoints['i/update']['req'] & {
hiddenRoleIds: string[];
};
const me = $i as MeDetailedWithRoleDisplay;
const isLocked = ref($i.isLocked);
const autoAcceptFollowed = ref($i.autoAcceptFollowed);
const noCrawle = ref($i.noCrawle);
@@ -242,6 +292,8 @@ const requireSigninToViewContents = ref($i.requireSigninToViewContents ?? false)
const makeNotesFollowersOnlyBefore = ref($i.makeNotesFollowersOnlyBefore ?? null);
const makeNotesHiddenBefore = ref($i.makeNotesHiddenBefore ?? null);
const hideOnlineStatus = ref($i.hideOnlineStatus);
const hiddenRoleIds = ref([...getHiddenRoleIds(me)]);
const roleDisplayRoles = computed(() => me.roles);
const publicReactions = ref($i.publicReactions);
const {
model: followingVisibility,
@@ -400,6 +452,35 @@ async function update_requireSigninToViewContents(value: boolean) {
save();
}
function getHiddenRoleIds(user: { hiddenRoleIds?: string[] }): string[] {
return user.hiddenRoleIds ?? [];
}
function isRoleDisplayShown(role: RoleDisplayRole): boolean {
return role.isPublicDisplayRequired === true || !hiddenRoleIds.value.includes(role.id);
}
async function updateRoleDisplay(role: RoleDisplayRole, visible: boolean) {
if (role.isPublicDisplayRequired === true) return;
const nextHiddenRoleIds = new Set(hiddenRoleIds.value);
if (visible) {
nextHiddenRoleIds.delete(role.id);
} else {
nextHiddenRoleIds.add(role.id);
}
const nextIds = roleDisplayRoles.value
.filter(role => role.isPublicDisplayRequired !== true && nextHiddenRoleIds.has(role.id))
.map(role => role.id);
const updated = await misskeyApi<MeDetailedWithRoleDisplay, 'i/update', IUpdateWithHiddenRoleIdsRequest>('i/update', {
hiddenRoleIds: nextIds,
});
updateCurrentAccount(updated);
hiddenRoleIds.value = [...getHiddenRoleIds(updated)];
}
function save() {
misskeyApi('i/update', {
isLocked: !!isLocked.value,
@@ -427,3 +508,24 @@ definePage(() => ({
icon: 'ti ti-lock-open',
}));
</script>
<style lang="scss" module>
.roleLabel {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--color, inherit);
}
.roleIcon {
display: inline-flex;
align-items: center;
width: 1.3em;
justify-content: center;
}
.roleBadge {
height: 1.3em;
vertical-align: -20%;
}
</style>

View File

@@ -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<typeof userDetailed>;
type ProfileRole = UserDetailed['roles'][number] & {
isPublicDisplayRequired?: boolean;
};
type MeRoleDisplay = NonNullable<typeof $i> & {
hiddenRoleIds?: string[];
};
function createProfileRole(id: string, name: string, color: string, isPublicDisplayRequired = false): ProfileRole {
return {
id,
name,
color,
iconUrl: null,
description: `${name} description`,
isModerator: false,
isAdministrator: false,
asBadge: false,
displayOrder: 0,
isPublicDisplayRequired,
};
}
const visibleRole = createProfileRole('role-display-visible', 'Visible Role', '#3b82f6');
const hiddenRole = createProfileRole('role-display-hidden', 'Hidden Role', '#ef4444');
const forcedRole = createProfileRole('role-display-forced', 'Forced Role', '#22c55e', true);
const roleDisplayUser = {
...userDetailed(),
roles: [visibleRole, hiddenRole, forcedRole],
};
function setStoryAccount(user: UserDetailed, hiddenRoleIds: string[]): void {
if ($i == null) return;
Object.assign($i as MeRoleDisplay, {
id: user.id,
username: user.username,
host: user.host,
name: user.name,
hiddenRoleIds,
});
}
function renderHome(args: UserDetailedHomeArgs, hiddenRoleIds: string[] = []) {
return {
components: {
home_,
},
setup() {
setStoryAccount(args.user, hiddenRoleIds);
return {
props: args,
};
},
template: '<home_ v-bind="props" />',
};
}
type UserDetailedHomeArgs = {
user: UserDetailed;
disableNotes?: boolean;
};
export const Default = {
render(args) {
return {
components: {
home_,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<home_ v-bind="props" />',
};
return renderHome(args);
},
args: {
user: userDetailed(),
@@ -79,3 +129,21 @@ export const Default = {
},
},
} satisfies StoryObj<typeof home_>;
export const RoleDisplayVisibility = {
...Default,
render(args) {
return renderHome(args, [hiddenRole.id]);
},
async play({ canvasElement }) {
const canvas = within(canvasElement);
await expect(await canvas.findByText(visibleRole.name)).toBeInTheDocument();
await expect(await canvas.findByText(forcedRole.name)).toBeInTheDocument();
await waitFor(() => expect(canvas.queryByText(hiddenRole.name)).not.toBeInTheDocument());
},
args: {
...Default.args,
user: roleDisplayUser,
},
} satisfies StoryObj<typeof home_>;

View File

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

View File

@@ -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": {
/**
* 選択されていません

View File

@@ -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<string, never>;
isPublic: boolean;
/** @default false */
isPublicDisplayRequired?: boolean;
isModerator: boolean;
isAdministrator: boolean;
/** @default false */
@@ -11209,6 +11220,7 @@ export interface operations {
target?: 'manual' | 'conditional';
condFormula?: Record<string, never>;
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)[];