From 95838a036e72bf963910c11bff492d08c8fdafa4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=BE=E3=81=A3=E3=81=A1=E3=82=83=E3=81=A8=E3=83=BC?=
 =?UTF-8?q?=E3=81=AB=E3=82=85?=
 <17376330+u1-liquid@users.noreply.github.com>
Date: Mon, 1 Apr 2024 20:12:15 +0900
Subject: [PATCH] =?UTF-8?q?spec(skeb/role):=20Skeb=E5=8B=9F=E9=9B=86?=
 =?UTF-8?q?=E4=B8=AD=E3=81=AE=E3=82=AF=E3=83=AA=E3=82=A8=E3=82=A4=E3=82=BF?=
 =?UTF-8?q?=E3=83=BC=E3=81=AB=E8=87=AA=E5=8B=95=E3=81=A7=E3=83=AD=E3=83=BC?=
 =?UTF-8?q?=E3=83=AB=E3=81=8C=E4=BB=98=E4=B8=8E=E3=81=95=E3=82=8C=E3=82=8B?=
 =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=E3=83=BB=E3=83=90=E3=83=83=E3=82=B8?=
 =?UTF-8?q?=E3=81=8B=E3=82=89=E5=8B=9F=E9=9B=86=E7=8A=B6=E6=85=8B=E3=81=AE?=
 =?UTF-8?q?=E7=A2=BA=E8=AA=8D=E3=81=8C=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88?=
 =?UTF-8?q?=E3=81=86=E3=81=AB=20(MisskeyIO#593)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 locales/en-US.yml                             |   2 +
 locales/index.d.ts                            |   8 +
 locales/ja-JP.yml                             |   2 +
 locales/ko-KR.yml                             |   2 +
 .../1711946753142-role-badge-behavior.js      |  11 ++
 packages/backend/src/config.ts                |   2 +
 packages/backend/src/core/RoleService.ts      |   1 +
 .../src/core/entities/RoleEntityService.ts    |   1 +
 .../src/core/entities/UserEntityService.ts    |   1 +
 packages/backend/src/models/Role.ts           |   5 +
 .../backend/src/models/json-schema/role.ts    |   4 +
 .../backend/src/models/json-schema/user.ts    |   4 +
 .../api/endpoints/admin/roles/create.ts       |   1 +
 .../api/endpoints/admin/roles/update.ts       |   2 +
 .../api/endpoints/users/get-skeb-status.ts    |  13 +-
 packages/backend/test/e2e/note.ts             |   4 +
 packages/backend/test/e2e/users.ts            |  19 ++-
 packages/backend/test/utils.ts                |   1 +
 packages/frontend/src/components/MkModal.vue  |  24 +--
 .../frontend/src/components/MkNoteHeader.vue  |   3 +-
 .../src/components/MkRoleBadgeIcon.vue        |  57 +++++++
 .../src/components/MkSkebStatusPopup.vue      | 140 ++++++++++++++++++
 .../src/components/MkUrlPreviewPopup.vue      |   4 +-
 .../frontend/src/pages/admin/roles.edit.vue   |   1 +
 .../frontend/src/pages/admin/roles.editor.vue |   6 +
 packages/frontend/src/pages/user/home.vue     |   1 -
 .../frontend/src/scripts/popup-position.ts    |  38 ++---
 .../frontend/src/scripts/use-chart-tooltip.ts |   6 +-
 packages/misskey-js/src/autogen/types.ts      |   4 +
 29 files changed, 323 insertions(+), 44 deletions(-)
 create mode 100644 packages/backend/migration/1711946753142-role-badge-behavior.js
 create mode 100644 packages/frontend/src/components/MkRoleBadgeIcon.vue
 create mode 100644 packages/frontend/src/components/MkSkebStatusPopup.vue

diff --git a/locales/en-US.yml b/locales/en-US.yml
index f2ef64beb4..7ee357e373 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -1677,6 +1677,8 @@ _role:
   iconUrl: "Icon URL"
   asBadge: "Show as badge"
   descriptionOfAsBadge: "This role's icon will be displayed next to the username of users with this role if turned on."
+  badgeBehavior: "Badge behavior"
+  descriptionOfBadgeBehavior: "Set the behavior of the badge icon."
   isExplorable: "Make role explorable"
   descriptionOfIsExplorable: "This role's timeline and the list of users with this will be made public if enabled."
   displayOrder: "Position"
diff --git a/locales/index.d.ts b/locales/index.d.ts
index ecce896b68..6c51ffc444 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -6558,6 +6558,14 @@ export interface Locale extends ILocale {
          * オンにすると、ユーザー名の横にロールのアイコンが表示されます。
          */
         "descriptionOfAsBadge": string;
+        /**
+         * バッジの挙動
+         */
+        "badgeBehavior": string;
+        /**
+         * バッジの挙動を設定します。
+         */
+        "descriptionOfBadgeBehavior": string;
         /**
          * ユーザーを見つけやすくする
          */
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 6ac6069596..632d13c2f8 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1695,6 +1695,8 @@ _role:
   iconUrl: "アイコン画像のURL"
   asBadge: "バッジとして表示"
   descriptionOfAsBadge: "オンにすると、ユーザー名の横にロールのアイコンが表示されます。"
+  badgeBehavior: "バッジの挙動"
+  descriptionOfBadgeBehavior: "バッジの挙動を設定します。"
   isExplorable: "ユーザーを見つけやすくする"
   descriptionOfIsExplorable: "オンにすると、「みつける」でメンバー一覧が公開されるほか、ロールのタイムラインが利用可能になります。"
   displayOrder: "表示順"
diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml
index 7f5fb8eb7d..9364e15b5f 100644
--- a/locales/ko-KR.yml
+++ b/locales/ko-KR.yml
@@ -1661,6 +1661,8 @@ _role:
   iconUrl: "아이콘 URL"
   asBadge: "뱃지로 표시"
   descriptionOfAsBadge: "활성화하면 유저명 옆에 역할의 아이콘이 표시됩니다."
+  badgeBehavior: "뱃지 동작"
+  descriptionOfBadgeBehavior: "뱃지의 동작 방식을 설정합니다."
   isExplorable: "역할 타임라인 공개"
   descriptionOfIsExplorable: "활성화하면 역할 타임라인을 공개합니다. 비활성화 시 타임라인이 공개되지 않습니다."
   displayOrder: "표시 순서"
diff --git a/packages/backend/migration/1711946753142-role-badge-behavior.js b/packages/backend/migration/1711946753142-role-badge-behavior.js
new file mode 100644
index 0000000000..3a73e806d7
--- /dev/null
+++ b/packages/backend/migration/1711946753142-role-badge-behavior.js
@@ -0,0 +1,11 @@
+export class RoleBadgeBehavior1711946753142 {
+    name = 'RoleBadgeBehavior1711946753142'
+
+    async up(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "role" ADD "badgeBehavior" character varying(256)`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "badgeBehavior"`);
+    }
+}
diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index 8502d1ef13..caf14e5aa0 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -72,6 +72,7 @@ type Source = {
 		headers: { [x: string]: string };
 		parameters: { [x: string]: string };
 		userIdParameterName: string;
+		roleId: string;
 	}
 
 	proxy?: string;
@@ -154,6 +155,7 @@ export type Config = {
 		headers: { [x: string]: string };
 		parameters: { [x: string]: string };
 		userIdParameterName: string;
+		roleId: string;
 	} | undefined;
 	proxy: string | undefined;
 	proxySmtp: string | undefined;
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 7607c711af..bc92baeeb1 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -577,6 +577,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
 			isModerator: values.isModerator,
 			isExplorable: values.isExplorable,
 			asBadge: values.asBadge,
+			badgeBehavior: values.badgeBehavior,
 			canEditMembersByModerator: values.canEditMembersByModerator,
 			displayOrder: values.displayOrder,
 			policies: values.policies,
diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts
index 49dee1138d..3e186e315e 100644
--- a/packages/backend/src/core/entities/RoleEntityService.ts
+++ b/packages/backend/src/core/entities/RoleEntityService.ts
@@ -68,6 +68,7 @@ export class RoleEntityService {
 			isModerator: role.isModerator,
 			isExplorable: role.isExplorable,
 			asBadge: role.asBadge,
+			badgeBehavior: role.badgeBehavior,
 			canEditMembersByModerator: role.canEditMembersByModerator,
 			displayOrder: role.displayOrder,
 			policies: policies,
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index 892529f7c3..4b6b032a8b 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -484,6 +484,7 @@ export class UserEntityService implements OnModuleInit {
 				name: r.name,
 				iconUrl: r.iconUrl,
 				displayOrder: r.displayOrder,
+				behavior: r.badgeBehavior ?? undefined,
 			}))) : undefined,
 
 			...(isDetailed ? {
diff --git a/packages/backend/src/models/Role.ts b/packages/backend/src/models/Role.ts
index 06bf43c32a..f7f1665288 100644
--- a/packages/backend/src/models/Role.ts
+++ b/packages/backend/src/models/Role.ts
@@ -155,6 +155,11 @@ export class MiRole {
 	})
 	public asBadge: boolean;
 
+	@Column('varchar', {
+		length: 256, nullable: true,
+	})
+	public badgeBehavior: string | null;
+
 	@Column('boolean', {
 		default: false,
 	})
diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts
index 0708142110..eed65a365c 100644
--- a/packages/backend/src/models/json-schema/role.ts
+++ b/packages/backend/src/models/json-schema/role.ts
@@ -384,6 +384,10 @@ export const packedRoleSchema = {
 					optional: false, nullable: false,
 					example: false,
 				},
+				badgeBehavior: {
+					type: 'string',
+					optional: false, nullable: true,
+				},
 				canEditMembersByModerator: {
 					type: 'boolean',
 					optional: false, nullable: false,
diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index 333cae1212..4531fcee06 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -176,6 +176,10 @@ export const packedUserLiteSchema = {
 						type: 'number',
 						nullable: false, optional: false,
 					},
+					behavior: {
+						type: 'string',
+						nullable: false, optional: true,
+					}
 				},
 			},
 		},
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/create.ts b/packages/backend/src/server/api/endpoints/admin/roles/create.ts
index e0c02f7a5d..b9658fa89b 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts
@@ -36,6 +36,7 @@ export const paramDef = {
 		isAdministrator: { type: 'boolean' },
 		isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility
 		asBadge: { type: 'boolean' },
+		badgeBehavior: { type: 'string', nullable: true },
 		canEditMembersByModerator: { type: 'boolean' },
 		displayOrder: { type: 'number' },
 		policies: {
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts
index 5242e0be2f..997a0a6930 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts
@@ -42,6 +42,7 @@ export const paramDef = {
 		isAdministrator: { type: 'boolean' },
 		isExplorable: { type: 'boolean' },
 		asBadge: { type: 'boolean' },
+		badgeBehavior: { type: 'string', nullable: true },
 		canEditMembersByModerator: { type: 'boolean' },
 		displayOrder: { type: 'number' },
 		policies: {
@@ -92,6 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				isAdministrator: ps.isAdministrator,
 				isExplorable: ps.isExplorable,
 				asBadge: ps.asBadge,
+				badgeBehavior: ps.badgeBehavior,
 				canEditMembersByModerator: ps.canEditMembersByModerator,
 				displayOrder: ps.displayOrder,
 				policies: ps.policies,
diff --git a/packages/backend/src/server/api/endpoints/users/get-skeb-status.ts b/packages/backend/src/server/api/endpoints/users/get-skeb-status.ts
index 79b23ae734..f2e1036651 100644
--- a/packages/backend/src/server/api/endpoints/users/get-skeb-status.ts
+++ b/packages/backend/src/server/api/endpoints/users/get-skeb-status.ts
@@ -1,6 +1,7 @@
 import { Inject, Injectable } from '@nestjs/common';
 import { DI } from '@/di-symbols.js';
 import { Endpoint } from '@/server/api/endpoint-base.js';
+import { RoleService } from '@/core/RoleService.js';
 import { LoggerService } from '@/core/LoggerService.js';
 import { HttpRequestService } from '@/core/HttpRequestService.js';
 import type { Config } from '@/config.js';
@@ -88,6 +89,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		@Inject(DI.config)
 		private config: Config,
 
+		private roleService: RoleService,
 		private loggerService: LoggerService,
 		private httpRequestService: HttpRequestService,
 	) {
@@ -128,13 +130,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				error?: unknown,
 			};
 
-			if (res.status > 399 || (json.error ?? json.ban_reason)) {
+			const hasSkebRole = await this.roleService.getUserRoles(ps.userId).then(roles => roles.some(r => r.id === this.config.skebStatus?.roleId));
+
+			if (res.status > 299 || (json.error ?? json.ban_reason)) {
 				logger.error('Skeb status response error', { url: url.href, userId: ps.userId, status: res.status, statusText: res.statusText, error: json.error ?? json.ban_reason });
+				if (res.status === 404 && hasSkebRole) await this.roleService.unassign(ps.userId, this.config.skebStatus.roleId);
 				throw new ApiError(meta.errors.noSuchUser);
 			}
 
 			logger.info('Skeb status response', { url: url.href, userId: ps.userId, status: res.status, statusText: res.statusText, skebStatus: json });
 
+			if (json.is_acceptable) {
+				if (!hasSkebRole) await this.roleService.assign(ps.userId, this.config.skebStatus.roleId);
+			} else if (hasSkebRole) {
+				await this.roleService.unassign(ps.userId, this.config.skebStatus.roleId);
+			}
+
 			return {
 				screenName: json.screen_name,
 				isCreator: json.is_creator,
diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts
index c027eebf38..479d24b7d4 100644
--- a/packages/backend/test/e2e/note.ts
+++ b/packages/backend/test/e2e/note.ts
@@ -467,6 +467,7 @@ describe('Note', () => {
 				isPublic: false,
 				isExplorable: false,
 				asBadge: false,
+				badgeBehavior: null,
 				canEditMembersByModerator: false,
 				policies: {
 					alwaysMarkNsfw: {
@@ -780,6 +781,7 @@ describe('Note', () => {
 				isPublic: false,
 				isExplorable: false,
 				asBadge: false,
+				badgeBehavior: null,
 				canEditMembersByModerator: false,
 				policies: {
 					mentionLimit: {
@@ -834,6 +836,7 @@ describe('Note', () => {
 				isPublic: false,
 				isExplorable: false,
 				asBadge: false,
+				badgeBehavior: null,
 				canEditMembersByModerator: false,
 				policies: {
 					mentionLimit: {
@@ -890,6 +893,7 @@ describe('Note', () => {
 				isPublic: false,
 				isExplorable: false,
 				asBadge: false,
+				badgeBehavior: null,
 				canEditMembersByModerator: false,
 				policies: {
 					mentionLimit: {
diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts
index f56b73b240..1d0d538461 100644
--- a/packages/backend/test/e2e/users.ts
+++ b/packages/backend/test/e2e/users.ts
@@ -651,11 +651,20 @@ describe('ユーザー', () => {
 	});
 	test('を取得することができ、バッヂロールがセットされていること', async () => {
 		const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice });
-		assert.deepStrictEqual(response.badgeRoles, [{
-			name: roleBadge.name,
-			iconUrl: roleBadge.iconUrl,
-			displayOrder: roleBadge.displayOrder,
-		}]);
+		if (roleBadge.badgeBehavior) {
+			assert.deepStrictEqual(response.badgeRoles, [{
+				name: roleBadge.name,
+				iconUrl: roleBadge.iconUrl,
+				displayOrder: roleBadge.displayOrder,
+				behavior: roleBadge.badgeBehavior,
+			}]);
+		} else {
+			assert.deepStrictEqual(response.badgeRoles, [{
+				name: roleBadge.name,
+				iconUrl: roleBadge.iconUrl,
+				displayOrder: roleBadge.displayOrder,
+			}]);
+		}
 		assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない
 	});
 	test('をID指定のリスト形式で取得することができる(空)', async () => {
diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts
index 342501d093..9e99517f03 100644
--- a/packages/backend/test/utils.ts
+++ b/packages/backend/test/utils.ts
@@ -251,6 +251,7 @@ export const channel = async (user: UserToken, channel: Partial<misskey.entities
 export const role = async (user: UserToken, role: Partial<misskey.entities.Role> = {}, policies: any = {}): Promise<misskey.entities.Role> => {
 	const res = await api('admin/roles/create', {
 		asBadge: false,
+		badgeBehavior: null,
 		canEditMembersByModerator: false,
 		color: null,
 		condFormula: {
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index 40e67fb4e0..eb240da759 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -175,8 +175,8 @@ const align = () => {
 	let left;
 	let top;
 
-	const x = srcRect.left + (fixed.value ? 0 : window.pageXOffset);
-	const y = srcRect.top + (fixed.value ? 0 : window.pageYOffset);
+	const x = srcRect.left + (fixed.value ? 0 : window.scrollX);
+	const y = srcRect.top + (fixed.value ? 0 : window.scrollY);
 
 	if (props.anchor.x === 'center') {
 		left = x + (props.src.offsetWidth / 2) - (width / 2);
@@ -220,24 +220,24 @@ const align = () => {
 		}
 	} else {
 		// 画面から横にはみ出る場合
-		if (left + width - window.pageXOffset > (window.innerWidth - SCROLLBAR_THICKNESS)) {
-			left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset - 1;
+		if (left + width - window.scrollX > (window.innerWidth - SCROLLBAR_THICKNESS)) {
+			left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.scrollX - 1;
 		}
 
-		const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - (top - window.pageYOffset);
+		const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - (top - window.scrollY);
 		const upperSpace = (srcRect.top - MARGIN);
 
 		// 画面から縦にはみ出る場合
-		if (top + height - window.pageYOffset > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) {
+		if (top + height - window.scrollY > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) {
 			if (props.noOverlap && props.anchor.x === 'center') {
 				if (underSpace >= (upperSpace / 3)) {
 					maxHeight.value = underSpace;
 				} else {
 					maxHeight.value = upperSpace;
-					top = window.pageYOffset + ((upperSpace + MARGIN) - height);
+					top = window.scrollY + ((upperSpace + MARGIN) - height);
 				}
 			} else {
-				top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height + window.pageYOffset - 1;
+				top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height + window.scrollY - 1;
 			}
 		} else {
 			maxHeight.value = underSpace;
@@ -255,15 +255,15 @@ const align = () => {
 	let transformOriginX = 'center';
 	let transformOriginY = 'center';
 
-	if (top >= srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.pageYOffset)) {
+	if (top >= srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.scrollY)) {
 		transformOriginY = 'top';
-	} else if ((top + height) <= srcRect.top + (fixed.value ? 0 : window.pageYOffset)) {
+	} else if ((top + height) <= srcRect.top + (fixed.value ? 0 : window.scrollY)) {
 		transformOriginY = 'bottom';
 	}
 
-	if (left >= srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.pageXOffset)) {
+	if (left >= srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.scrollX)) {
 		transformOriginX = 'left';
-	} else if ((left + width) <= srcRect.left + (fixed.value ? 0 : window.pageXOffset)) {
+	} else if ((left + width) <= srcRect.left + (fixed.value ? 0 : window.scrollX)) {
 		transformOriginX = 'right';
 	}
 
diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue
index be5829d92f..2f5132c285 100644
--- a/packages/frontend/src/components/MkNoteHeader.vue
+++ b/packages/frontend/src/components/MkNoteHeader.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<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!"/>
+		<MkRoleBadgeIcon v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :userId="note.user.id" :role="role" :class="$style.badgeRole"/>
 	</div>
 	<div :class="$style.info">
 		<div v-if="mock">
@@ -40,6 +40,7 @@ import * as Misskey from 'misskey-js';
 import { i18n } from '@/i18n.js';
 import { notePage } from '@/filters/note.js';
 import { userPage } from '@/filters/user.js';
+import MkRoleBadgeIcon from '@/components/MkRoleBadgeIcon.vue';
 
 defineProps<{
 	note: Misskey.entities.Note;
diff --git a/packages/frontend/src/components/MkRoleBadgeIcon.vue b/packages/frontend/src/components/MkRoleBadgeIcon.vue
new file mode 100644
index 0000000000..e4bafe6bc4
--- /dev/null
+++ b/packages/frontend/src/components/MkRoleBadgeIcon.vue
@@ -0,0 +1,57 @@
+<template>
+<img ref="el" :src="role.iconUrl!" @click="onClick(role)"/>
+</template>
+
+<script setup lang="ts">
+import { defineAsyncComponent, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import * as os from '@/os.js';
+import { instance } from '@/instance.js';
+import { misskeyApiGet } from '@/scripts/misskey-api.js';
+import { useTooltip } from '@/scripts/use-tooltip.js';
+
+const props = defineProps<{
+	userId: string,
+	role: { name: string, iconUrl: string | null, displayOrder: number, behavior?: string }
+}>();
+
+const el = ref<HTMLElement | { $el: HTMLElement }>();
+const userSkebStatus = ref<Misskey.Endpoints['users/get-skeb-status']['res'] | null>(null);
+
+async function fetchSkebStatus() {
+	if (!instance.enableSkebStatus || props.role.behavior !== 'skeb') {
+		userSkebStatus.value = null;
+		return;
+	}
+
+	userSkebStatus.value = await misskeyApiGet('users/get-skeb-status', { userId: props.userId });
+}
+
+if (props.role.behavior === 'skeb') {
+	useTooltip(el, async (showing) => {
+		if (userSkebStatus.value == null) {
+			await fetchSkebStatus();
+		}
+
+		if (userSkebStatus.value === null) return;
+
+		os.popup(defineAsyncComponent(() => import('@/components/MkSkebStatusPopup.vue')), {
+			showing,
+			skebStatus: userSkebStatus.value,
+			source: el.value instanceof HTMLElement ? el.value : el.value?.$el,
+		}, {}, 'closed');
+	});
+}
+
+async function onClick(role: { name: string, iconUrl: string | null, displayOrder: number, behavior?: string }) {
+	if (role.behavior === 'skeb') {
+		if (userSkebStatus.value == null) {
+			await fetchSkebStatus();
+		}
+
+		if (userSkebStatus.value != null) {
+			window.open(`https://skeb.jp/@${userSkebStatus.value.screenName}`, '_blank', 'noopener');
+		}
+	}
+}
+</script>
diff --git a/packages/frontend/src/components/MkSkebStatusPopup.vue b/packages/frontend/src/components/MkSkebStatusPopup.vue
new file mode 100644
index 0000000000..bb8c46d964
--- /dev/null
+++ b/packages/frontend/src/components/MkSkebStatusPopup.vue
@@ -0,0 +1,140 @@
+<template>
+<div ref="el" :class="$style.root" :style="{ zIndex }">
+	<Transition
+		:name="defaultStore.state.animation ? '_transition_zoom' : ''"
+		@afterLeave="emit('closed')"
+	>
+		<div v-if="showing" class="_popup _shadow">
+			<article :class="$style.body">
+				<header :class="$style.header">
+					<span v-if="skebStatus.isAcceptable" :class="$style.skebAcceptable">
+						{{ i18n.ts._skebStatus.seeking }}
+					</span>
+					<span v-else-if="skebStatus.isCreator" :class="$style.skebStopped">
+						{{ i18n.ts._skebStatus.stopped }}
+					</span>
+					<span v-else :class="$style.skebClient">
+						{{ i18n.ts._skebStatus.client }}
+					</span>
+					<Mfm v-if="skebStatus.creatorRequestCount > 0" :text="i18n.tsx._skebStatus.nWorks({ n: skebStatus.creatorRequestCount.toLocaleString() })" :nyaize="false" :colored="false"/>
+					<Mfm v-else-if="skebStatus.clientRequestCount > 0" :text="i18n.tsx._skebStatus.nRequests({ n: skebStatus.clientRequestCount.toLocaleString() })" :nyaize="false" :colored="false"/>
+				</header>
+				<div v-if="skebStatus.isAcceptable" :class="$style.divider"></div>
+				<div v-if="skebStatus.isAcceptable" class="contents _gaps_s">
+					<Mfm v-for="skill in skebStatus.skills" :key="skill.genre" :text="`${i18n.ts._skebStatus._genres[skill.genre]} ${i18n.tsx._skebStatus.yenX({ x: skill.amount.toLocaleString() })}`" :nyaize="false" :colored="false"/>
+				</div>
+			</article>
+		</div>
+	</Transition>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, onMounted, onUnmounted, shallowRef } from 'vue';
+import * as Misskey from 'misskey-js';
+import * as os from '@/os.js';
+import { defaultStore } from '@/store.js';
+import { i18n } from '@/i18n.js';
+import { calcPopupPosition } from '@/scripts/popup-position.js';
+
+const props = defineProps<{
+	showing: boolean;
+	skebStatus: Misskey.Endpoints['users/get-skeb-status']['res'];
+	source: HTMLElement;
+}>();
+
+const emit = defineEmits<(ev: 'closed') => void>();
+
+// タイミングによっては最初から showing = false な場合があり、その場合に closed 扱いにしないと永久にDOMに残ることになる
+if (!props.showing) emit('closed');
+
+const el = shallowRef<HTMLElement>();
+const zIndex = os.claimZIndex('high');
+
+function setPosition() {
+	if (el.value == null) return;
+
+	const data = calcPopupPosition(el.value, {
+		anchorElement: props.source,
+		direction: 'bottom',
+		align: 'center',
+		innerMargin: 0,
+	});
+
+	el.value.style.transformOrigin = data.transformOrigin;
+	el.value.style.left = data.left + 'px';
+	el.value.style.top = data.top + 'px';
+}
+
+let loopHandler;
+
+onMounted(() => {
+	nextTick(() => {
+		setPosition();
+
+		const loop = () => {
+			setPosition();
+			loopHandler = window.requestAnimationFrame(loop);
+		};
+
+		loop();
+	});
+});
+
+onUnmounted(() => {
+	window.cancelAnimationFrame(loopHandler);
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	position: absolute;
+	padding: 8px 12px;
+	text-align: center;
+	pointer-events: none;
+	transform-origin: center center;
+}
+
+.body {
+	position: relative;
+	box-sizing: border-box;
+	padding: 16px;
+}
+
+.header {
+	display: flex;
+	gap: 2px;
+	align-items: center;
+}
+
+.divider {
+	margin: 8px auto;
+	border-top: solid 0.5px var(--divider);
+}
+
+.skebAcceptable,
+.skebStopped,
+.skebClient {
+	display: inline-flex;
+	border: solid 1px;
+	border-radius: 6px;
+	padding: 2px 6px;
+	margin-right: 4px;
+	font-size: 85%;
+}
+
+.skebAcceptable {
+	color: rgb(255, 255, 255);
+	background-color: rgb(241, 70, 104);
+}
+
+.skebStopped {
+	color: rgb(255, 255, 255);
+	background-color: rgb(54, 54, 54);
+}
+
+.skebClient {
+	color: rgb(255, 255, 255);
+	background-color: rgb(54, 54, 54);
+}
+</style>
diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue
index 004944e578..2d5b54cd96 100644
--- a/packages/frontend/src/components/MkUrlPreviewPopup.vue
+++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue
@@ -34,8 +34,8 @@ const left = ref(0);
 onMounted(() => {
 	try {
 		const rect = props.source.getBoundingClientRect();
-		const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.pageXOffset;
-		const y = rect.top + props.source.offsetHeight + window.pageYOffset;
+		const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.scrollX;
+		const y = rect.top + props.source.offsetHeight + window.scrollY;
 
 		top.value = y;
 		left.value = x;
diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue
index 60f06d50ba..d572f197e5 100644
--- a/packages/frontend/src/pages/admin/roles.edit.vue
+++ b/packages/frontend/src/pages/admin/roles.edit.vue
@@ -63,6 +63,7 @@ if (props.id) {
 		isPublic: false,
 		isExplorable: false,
 		asBadge: false,
+		badgeBehavior: null,
 		canEditMembersByModerator: false,
 		displayOrder: 0,
 		policies: {},
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 7143bc4de6..d4aa50d9ef 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -67,6 +67,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<template #caption>{{ i18n.ts._role.descriptionOfAsBadge }}</template>
 	</MkSwitch>
 
+	<MkInput v-if="role.asBadge" v-model="role.badgeBehavior" :readonly="readonly">
+		<template #label>{{ i18n.ts._role.badgeBehavior }}</template>
+		<template #caption>{{ i18n.ts._role.descriptionOfBadgeBehavior }}</template>
+	</MkInput>
+
 	<MkSwitch v-model="role.isExplorable" :readonly="readonly">
 		<template #label>{{ i18n.ts._role.isExplorable }}</template>
 		<template #caption>{{ i18n.ts._role.descriptionOfIsExplorable }}</template>
@@ -848,6 +853,7 @@ const save = throttle(100, () => {
 		isPublic: role.value.isPublic,
 		isExplorable: role.value.isExplorable,
 		asBadge: role.value.asBadge,
+		badgeBehavior: role.value.badgeBehavior,
 		canEditMembersByModerator: role.value.canEditMembersByModerator,
 		policies: role.value.policies,
 	};
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 9831f40504..735b7d2f55 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -319,7 +319,6 @@ async function fetchSkebStatus() {
 		return;
 	}
 
-	console.log('fetching skeb status');
 	userSkebStatus.value = await misskeyApiGet('users/get-skeb-status', { userId: props.user.id });
 }
 
diff --git a/packages/frontend/src/scripts/popup-position.ts b/packages/frontend/src/scripts/popup-position.ts
index 8c9e3c02c3..3dad41a8b3 100644
--- a/packages/frontend/src/scripts/popup-position.ts
+++ b/packages/frontend/src/scripts/popup-position.ts
@@ -26,8 +26,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
 		let top: number;
 
 		if (props.anchorElement) {
-			left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2);
-			top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin;
+			left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2);
+			top = (rect.top + window.scrollY - contentHeight) - props.innerMargin;
 		} else {
 			left = props.x;
 			top = (props.y - contentHeight) - props.innerMargin;
@@ -35,8 +35,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
 
 		left -= (el.offsetWidth / 2);
 
-		if (left + contentWidth - window.pageXOffset > window.innerWidth) {
-			left = window.innerWidth - contentWidth + window.pageXOffset - 1;
+		if (left + contentWidth - window.scrollX > window.innerWidth) {
+			left = window.innerWidth - contentWidth + window.scrollX - 1;
 		}
 
 		return [left, top];
@@ -47,8 +47,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
 		let top: number;
 
 		if (props.anchorElement) {
-			left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2);
-			top = (rect.top + window.pageYOffset + props.anchorElement.offsetHeight) + props.innerMargin;
+			left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2);
+			top = (rect.top + window.scrollY + props.anchorElement.offsetHeight) + props.innerMargin;
 		} else {
 			left = props.x;
 			top = (props.y) + props.innerMargin;
@@ -56,8 +56,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
 
 		left -= (el.offsetWidth / 2);
 
-		if (left + contentWidth - window.pageXOffset > window.innerWidth) {
-			left = window.innerWidth - contentWidth + window.pageXOffset - 1;
+		if (left + contentWidth - window.scrollX > window.innerWidth) {
+			left = window.innerWidth - contentWidth + window.scrollX - 1;
 		}
 
 		return [left, top];
@@ -68,8 +68,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
 		let top: number;
 
 		if (props.anchorElement) {
-			left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin;
-			top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2);
+			left = (rect.left + window.scrollX - contentWidth) - props.innerMargin;
+			top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2);
 		} else {
 			left = (props.x - contentWidth) - props.innerMargin;
 			top = props.y;
@@ -77,8 +77,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
 
 		top -= (el.offsetHeight / 2);
 
-		if (top + contentHeight - window.pageYOffset > window.innerHeight) {
-			top = window.innerHeight - contentHeight + window.pageYOffset - 1;
+		if (top + contentHeight - window.scrollY > window.innerHeight) {
+			top = window.innerHeight - contentHeight + window.scrollY - 1;
 		}
 
 		return [left, top];
@@ -89,15 +89,15 @@ export function calcPopupPosition(el: HTMLElement, props: {
 		let top: number;
 
 		if (props.anchorElement) {
-			left = (rect.left + props.anchorElement.offsetWidth + window.pageXOffset) + props.innerMargin;
+			left = (rect.left + props.anchorElement.offsetWidth + window.scrollX) + props.innerMargin;
 
 			if (props.align === 'top') {
-				top = rect.top + window.pageYOffset;
+				top = rect.top + window.scrollY;
 				if (props.alignOffset != null) top += props.alignOffset;
 			} else if (props.align === 'bottom') {
 				// TODO
 			} else { // center
-				top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2);
+				top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2);
 				top -= (el.offsetHeight / 2);
 			}
 		} else {
@@ -106,8 +106,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
 			top -= (el.offsetHeight / 2);
 		}
 
-		if (top + contentHeight - window.pageYOffset > window.innerHeight) {
-			top = window.innerHeight - contentHeight + window.pageYOffset - 1;
+		if (top + contentHeight - window.scrollY > window.innerHeight) {
+			top = window.innerHeight - contentHeight + window.scrollY - 1;
 		}
 
 		return [left, top];
@@ -123,7 +123,7 @@ export function calcPopupPosition(el: HTMLElement, props: {
 				const [left, top] = calcPosWhenTop();
 
 				// ツールチップを上に向かって表示するスペースがなければ下に向かって出す
-				if (top - window.pageYOffset < 0) {
+				if (top - window.scrollY < 0) {
 					const [left, top] = calcPosWhenBottom();
 					return { left, top, transformOrigin: 'center top' };
 				}
@@ -141,7 +141,7 @@ export function calcPopupPosition(el: HTMLElement, props: {
 				const [left, top] = calcPosWhenLeft();
 
 				// ツールチップを左に向かって表示するスペースがなければ右に向かって出す
-				if (left - window.pageXOffset < 0) {
+				if (left - window.scrollX < 0) {
 					const [left, top] = calcPosWhenRight();
 					return { left, top, transformOrigin: 'left center' };
 				}
diff --git a/packages/frontend/src/scripts/use-chart-tooltip.ts b/packages/frontend/src/scripts/use-chart-tooltip.ts
index 7e4bf5c9c6..bed221a622 100644
--- a/packages/frontend/src/scripts/use-chart-tooltip.ts
+++ b/packages/frontend/src/scripts/use-chart-tooltip.ts
@@ -53,11 +53,11 @@ export function useChartTooltip(opts: { position: 'top' | 'middle' } = { positio
 		const rect = context.chart.canvas.getBoundingClientRect();
 
 		tooltipShowing.value = true;
-		tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX;
+		tooltipX.value = rect.left + window.scrollX + context.tooltip.caretX;
 		if (opts.position === 'top') {
-			tooltipY.value = rect.top + window.pageYOffset;
+			tooltipY.value = rect.top + window.scrollY;
 		} else if (opts.position === 'middle') {
-			tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY;
+			tooltipY.value = rect.top + window.scrollY + context.tooltip.caretY;
 		}
 	}
 
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index f4e5047116..b2bca962f6 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -3748,6 +3748,7 @@ export type components = {
           name: string;
           iconUrl: string | null;
           displayOrder: number;
+          behavior?: string;
         })[];
     };
     UserDetailedNotMeOnly: {
@@ -4843,6 +4844,7 @@ export type components = {
       isExplorable: boolean;
       /** @example false */
       asBadge: boolean;
+      badgeBehavior: string | null;
       /** @example false */
       canEditMembersByModerator: boolean;
       policies: {
@@ -9827,6 +9829,7 @@ export type operations = {
           /** @default false */
           isExplorable?: boolean;
           asBadge: boolean;
+          badgeBehavior?: string | null;
           canEditMembersByModerator: boolean;
           displayOrder: number;
           policies: Record<string, never>;
@@ -10048,6 +10051,7 @@ export type operations = {
           isAdministrator: boolean;
           isExplorable?: boolean;
           asBadge: boolean;
+          badgeBehavior?: string | null;
           canEditMembersByModerator: boolean;
           displayOrder: number;
           policies: Record<string, never>;