feat: ロールにアイコンを設定してユーザー名の横に表示できるように

Resolve #9761
This commit is contained in:
syuilo 2023-02-05 10:37:03 +09:00
parent 868c8fffb3
commit 6a3039f7b7
12 changed files with 107 additions and 12 deletions

View file

@ -11,6 +11,7 @@ You should also include the user name that made the change.
## 13.x.x (unreleased) ## 13.x.x (unreleased)
### Improvements ### Improvements
- ロールにアイコンを設定してユーザー名の横に表示できるように
### Bugfixes ### Bugfixes
- -

View file

@ -1184,7 +1184,7 @@ _role:
description: "ロールの説明" description: "ロールの説明"
permission: "ロールの権限" permission: "ロールの権限"
descriptionOfPermission: "<b>モデレーター</b>は基本的なモデレーションに関する操作を行えます。\n<b>管理者</b>はインスタンスの全ての設定を変更できます。" descriptionOfPermission: "<b>モデレーター</b>は基本的なモデレーションに関する操作を行えます。\n<b>管理者</b>はインスタンスの全ての設定を変更できます。"
assignTarget: "アサインターゲット" assignTarget: "アサイン"
descriptionOfAssignTarget: "<b>マニュアル</b>は誰がこのロールに含まれるかを手動で管理します。\n<b>コンディショナル</b>は条件を設定し、それに合致するユーザーが自動で含まれるようになります。" descriptionOfAssignTarget: "<b>マニュアル</b>は誰がこのロールに含まれるかを手動で管理します。\n<b>コンディショナル</b>は条件を設定し、それに合致するユーザーが自動で含まれるようになります。"
manual: "マニュアル" manual: "マニュアル"
conditional: "コンディショナル" conditional: "コンディショナル"
@ -1197,6 +1197,9 @@ _role:
baseRole: "ベースロール" baseRole: "ベースロール"
useBaseValue: "ベースロールの値を使用" useBaseValue: "ベースロールの値を使用"
chooseRoleToAssign: "アサインするロールを選択" chooseRoleToAssign: "アサインするロールを選択"
iconUrl: "アイコン画像のURL"
asBadge: "バッジとして表示"
descriptionOfAsBadge: "オンにすると、ユーザー名の横にロールのアイコンが表示されます。"
canEditMembersByModerator: "モデレーターのメンバー編集を許可" canEditMembersByModerator: "モデレーターのメンバー編集を許可"
descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになります。オフにすると管理者のみが行えます。" descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになります。オフにすると管理者のみが行えます。"
priority: "優先度" priority: "優先度"

View file

@ -0,0 +1,13 @@
export class roleIconBadge1675557528704 {
name = 'roleIconBadge1675557528704'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "role" ADD "iconUrl" character varying(512)`);
await queryRunner.query(`ALTER TABLE "role" ADD "asBadge" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "asBadge"`);
await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "iconUrl"`);
}
}

View file

@ -202,6 +202,19 @@ export class RoleService implements OnApplicationShutdown {
return [...assignedRoles, ...matchedCondRoles]; return [...assignedRoles, ...matchedCondRoles];
} }
/**
*
*/
@bindThis
public async getUserBadgeRoles(userId: User['id']) {
const assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
const assignedRoleIds = assigns.map(x => x.roleId);
const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({}));
const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id));
// コンディショナルロールも含めるのは負荷高そうだから一旦無し
return assignedBadgeRoles;
}
@bindThis @bindThis
public async getUserPolicies(userId: User['id'] | null): Promise<RolePolicies> { public async getUserPolicies(userId: User['id'] | null): Promise<RolePolicies> {
const meta = await this.metaService.fetch(); const meta = await this.metaService.fetch();

View file

@ -56,11 +56,13 @@ export class RoleEntityService {
name: role.name, name: role.name,
description: role.description, description: role.description,
color: role.color, color: role.color,
iconUrl: role.iconUrl,
target: role.target, target: role.target,
condFormula: role.condFormula, condFormula: role.condFormula,
isPublic: role.isPublic, isPublic: role.isPublic,
isAdministrator: role.isAdministrator, isAdministrator: role.isAdministrator,
isModerator: role.isModerator, isModerator: role.isModerator,
asBadge: role.asBadge,
canEditMembersByModerator: role.canEditMembersByModerator, canEditMembersByModerator: role.canEditMembersByModerator,
policies: policies, policies: policies,
usersCount: assigns.length, usersCount: assigns.length,

View file

@ -415,6 +415,11 @@ export class UserEntityService implements OnModuleInit {
} : undefined) : undefined, } : undefined) : undefined,
emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), emojis: this.customEmojiService.populateEmojis(user.emojis, user.host),
onlineStatus: this.getOnlineStatus(user), onlineStatus: this.getOnlineStatus(user),
// パフォーマンス上の理由でローカルユーザーのみ
badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then(rs => rs.map(r => ({
name: r.name,
iconUrl: r.iconUrl,
}))) : undefined,
...(opts.detail ? { ...(opts.detail ? {
url: profile!.url, url: profile!.url,
@ -454,6 +459,7 @@ export class UserEntityService implements OnModuleInit {
id: role.id, id: role.id,
name: role.name, name: role.name,
color: role.color, color: role.color,
iconUrl: role.iconUrl,
description: role.description, description: role.description,
isModerator: role.isModerator, isModerator: role.isModerator,
isAdministrator: role.isAdministrator, isAdministrator: role.isAdministrator,

View file

@ -102,6 +102,11 @@ export class Role {
}) })
public color: string | null; public color: string | null;
@Column('varchar', {
length: 512, nullable: true,
})
public iconUrl: string | null;
@Column('enum', { @Column('enum', {
enum: ['manual', 'conditional'], enum: ['manual', 'conditional'],
default: 'manual', default: 'manual',
@ -118,6 +123,12 @@ export class Role {
}) })
public isPublic: boolean; public isPublic: boolean;
// trueの場合ユーザー名の横にバッジとして表示
@Column('boolean', {
default: false,
})
public asBadge: boolean;
@Column('boolean', { @Column('boolean', {
default: false, default: false,
}) })

View file

@ -19,11 +19,13 @@ export const paramDef = {
name: { type: 'string' }, name: { type: 'string' },
description: { type: 'string' }, description: { type: 'string' },
color: { type: 'string', nullable: true }, color: { type: 'string', nullable: true },
iconUrl: { type: 'string', nullable: true },
target: { type: 'string' }, target: { type: 'string' },
condFormula: { type: 'object' }, condFormula: { type: 'object' },
isPublic: { type: 'boolean' }, isPublic: { type: 'boolean' },
isModerator: { type: 'boolean' }, isModerator: { type: 'boolean' },
isAdministrator: { type: 'boolean' }, isAdministrator: { type: 'boolean' },
asBadge: { type: 'boolean' },
canEditMembersByModerator: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' },
policies: { policies: {
type: 'object', type: 'object',
@ -33,11 +35,13 @@ export const paramDef = {
'name', 'name',
'description', 'description',
'color', 'color',
'iconUrl',
'target', 'target',
'condFormula', 'condFormula',
'isPublic', 'isPublic',
'isModerator', 'isModerator',
'isAdministrator', 'isAdministrator',
'asBadge',
'canEditMembersByModerator', 'canEditMembersByModerator',
'policies', 'policies',
], ],
@ -64,11 +68,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
name: ps.name, name: ps.name,
description: ps.description, description: ps.description,
color: ps.color, color: ps.color,
iconUrl: ps.iconUrl,
target: ps.target, target: ps.target,
condFormula: ps.condFormula, condFormula: ps.condFormula,
isPublic: ps.isPublic, isPublic: ps.isPublic,
isAdministrator: ps.isAdministrator, isAdministrator: ps.isAdministrator,
isModerator: ps.isModerator, isModerator: ps.isModerator,
asBadge: ps.asBadge,
canEditMembersByModerator: ps.canEditMembersByModerator, canEditMembersByModerator: ps.canEditMembersByModerator,
policies: ps.policies, policies: ps.policies,
}).then(x => this.rolesRepository.findOneByOrFail(x.identifiers[0])); }).then(x => this.rolesRepository.findOneByOrFail(x.identifiers[0]));

View file

@ -27,11 +27,13 @@ export const paramDef = {
name: { type: 'string' }, name: { type: 'string' },
description: { type: 'string' }, description: { type: 'string' },
color: { type: 'string', nullable: true }, color: { type: 'string', nullable: true },
iconUrl: { type: 'string', nullable: true },
target: { type: 'string' }, target: { type: 'string' },
condFormula: { type: 'object' }, condFormula: { type: 'object' },
isPublic: { type: 'boolean' }, isPublic: { type: 'boolean' },
isModerator: { type: 'boolean' }, isModerator: { type: 'boolean' },
isAdministrator: { type: 'boolean' }, isAdministrator: { type: 'boolean' },
asBadge: { type: 'boolean' },
canEditMembersByModerator: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' },
policies: { policies: {
type: 'object', type: 'object',
@ -42,11 +44,13 @@ export const paramDef = {
'name', 'name',
'description', 'description',
'color', 'color',
'iconUrl',
'target', 'target',
'condFormula', 'condFormula',
'isPublic', 'isPublic',
'isModerator', 'isModerator',
'isAdministrator', 'isAdministrator',
'asBadge',
'canEditMembersByModerator', 'canEditMembersByModerator',
'policies', 'policies',
], ],
@ -73,11 +77,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
name: ps.name, name: ps.name,
description: ps.description, description: ps.description,
color: ps.color, color: ps.color,
iconUrl: ps.iconUrl,
target: ps.target, target: ps.target,
condFormula: ps.condFormula, condFormula: ps.condFormula,
isPublic: ps.isPublic, isPublic: ps.isPublic,
isModerator: ps.isModerator, isModerator: ps.isModerator,
isAdministrator: ps.isAdministrator, isAdministrator: ps.isAdministrator,
asBadge: ps.asBadge,
canEditMembersByModerator: ps.canEditMembersByModerator, canEditMembersByModerator: ps.canEditMembersByModerator,
policies: ps.policies, policies: ps.policies,
}); });

View file

@ -5,6 +5,9 @@
</MkA> </MkA>
<div v-if="note.user.isBot" :class="$style.isBot">bot</div> <div v-if="note.user.isBot" :class="$style.isBot">bot</div>
<div :class="$style.username"><MkAcct :user="note.user"/></div> <div :class="$style.username"><MkAcct :user="note.user"/></div>
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
<img v-for="role in note.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/>
</div>
<div :class="$style.info"> <div :class="$style.info">
<MkA :to="notePage(note)"> <MkA :to="notePage(note)">
<MkTime :time="note.createdAt"/> <MkTime :time="note.createdAt"/>
@ -77,4 +80,17 @@ defineProps<{
margin-left: auto; margin-left: auto;
font-size: 0.9em; font-size: 0.9em;
} }
.badgeRoles {
margin: 0 .5em 0 0;
}
.badgeRole {
height: 1.3em;
vertical-align: -20%;
& + .badgeRole {
margin-left: .125em;
}
}
</style> </style>

View file

@ -13,6 +13,10 @@
<template #caption>#RRGGBB</template> <template #caption>#RRGGBB</template>
</MkInput> </MkInput>
<MkInput v-model="iconUrl">
<template #label>{{ i18n.ts._role.iconUrl }}</template>
</MkInput>
<MkSelect v-model="rolePermission" :readonly="readonly"> <MkSelect v-model="rolePermission" :readonly="readonly">
<template #label><i class="ti ti-shield-lock"></i> {{ i18n.ts._role.permission }}</template> <template #label><i class="ti ti-shield-lock"></i> {{ i18n.ts._role.permission }}</template>
<template #caption><div v-html="i18n.ts._role.descriptionOfPermission.replaceAll('\n', '<br>')"></div></template> <template #caption><div v-html="i18n.ts._role.descriptionOfPermission.replaceAll('\n', '<br>')"></div></template>
@ -35,6 +39,21 @@
</div> </div>
</MkFolder> </MkFolder>
<MkSwitch v-model="canEditMembersByModerator" :readonly="readonly">
<template #label>{{ i18n.ts._role.canEditMembersByModerator }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfCanEditMembersByModerator }}</template>
</MkSwitch>
<MkSwitch v-model="isPublic" :readonly="readonly">
<template #label>{{ i18n.ts._role.isPublic }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfIsPublic }}</template>
</MkSwitch>
<MkSwitch v-model="asBadge" :readonly="readonly">
<template #label>{{ i18n.ts._role.asBadge }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfAsBadge }}</template>
</MkSwitch>
<FormSlot> <FormSlot>
<template #label><i class="ti ti-license"></i> {{ i18n.ts._role.policies }}</template> <template #label><i class="ti ti-license"></i> {{ i18n.ts._role.policies }}</template>
<div class="_gaps_s"> <div class="_gaps_s">
@ -358,16 +377,6 @@
</div> </div>
</FormSlot> </FormSlot>
<MkSwitch v-model="canEditMembersByModerator" :readonly="readonly">
<template #label>{{ i18n.ts._role.canEditMembersByModerator }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfCanEditMembersByModerator }}</template>
</MkSwitch>
<MkSwitch v-model="isPublic" :readonly="readonly">
<template #label>{{ i18n.ts._role.isPublic }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfIsPublic }}</template>
</MkSwitch>
<div v-if="!readonly" class="_buttons"> <div v-if="!readonly" class="_buttons">
<MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ role ? i18n.ts.save : i18n.ts.create }}</MkButton> <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ role ? i18n.ts.save : i18n.ts.create }}</MkButton>
</div> </div>
@ -426,9 +435,11 @@ let name = $ref(role?.name ?? 'New Role');
let description = $ref(role?.description ?? ''); let description = $ref(role?.description ?? '');
let rolePermission = $ref(role?.isAdministrator ? 'administrator' : role?.isModerator ? 'moderator' : 'normal'); let rolePermission = $ref(role?.isAdministrator ? 'administrator' : role?.isModerator ? 'moderator' : 'normal');
let color = $ref(role?.color ?? null); let color = $ref(role?.color ?? null);
let iconUrl = $ref(role?.iconUrl ?? null);
let target = $ref(role?.target ?? 'manual'); let target = $ref(role?.target ?? 'manual');
let condFormula = $ref(role?.condFormula ?? { id: uuid(), type: 'isRemote' }); let condFormula = $ref(role?.condFormula ?? { id: uuid(), type: 'isRemote' });
let isPublic = $ref(role?.isPublic ?? false); let isPublic = $ref(role?.isPublic ?? false);
let asBadge = $ref(role?.asBadge ?? false);
let canEditMembersByModerator = $ref(role?.canEditMembersByModerator ?? false); let canEditMembersByModerator = $ref(role?.canEditMembersByModerator ?? false);
const policies = reactive<Record<typeof ROLE_POLICIES[number], { useDefault: boolean; priority: number; value: any; }>>({}); const policies = reactive<Record<typeof ROLE_POLICIES[number], { useDefault: boolean; priority: number; value: any; }>>({});
@ -466,11 +477,13 @@ async function save() {
name, name,
description, description,
color: color === '' ? null : color, color: color === '' ? null : color,
iconUrl: iconUrl === '' ? null : iconUrl,
target, target,
condFormula, condFormula,
isAdministrator: rolePermission === 'administrator', isAdministrator: rolePermission === 'administrator',
isModerator: rolePermission === 'moderator', isModerator: rolePermission === 'moderator',
isPublic, isPublic,
asBadge,
canEditMembersByModerator, canEditMembersByModerator,
policies, policies,
}); });
@ -480,11 +493,13 @@ async function save() {
name, name,
description, description,
color: color === '' ? null : color, color: color === '' ? null : color,
iconUrl: iconUrl === '' ? null : iconUrl,
target, target,
condFormula, condFormula,
isAdministrator: rolePermission === 'administrator', isAdministrator: rolePermission === 'administrator',
isModerator: rolePermission === 'moderator', isModerator: rolePermission === 'moderator',
isPublic, isPublic,
asBadge,
canEditMembersByModerator, canEditMembersByModerator,
policies, policies,
}); });

View file

@ -39,7 +39,10 @@
</div> </div>
</div> </div>
<div v-if="user.roles.length > 0" class="roles"> <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 }">{{ role.name }}</span> <span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color }">
<img v-if="role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="role.iconUrl"/>
{{ role.name }}
</span>
</div> </div>
<div class="description"> <div class="description">
<MkOmit> <MkOmit>