From efc5d97eab85453ff7d009c00d3b8308c9e18898 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: Fri, 29 Mar 2024 17:05:02 +0900
Subject: [PATCH] =?UTF-8?q?spec(frontend):=20Skeb=20Button=E3=81=AE?=
 =?UTF-8?q?=E5=AE=9F=E8=A3=85=20(MisskeyIO#573)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 locales/en-US.yml                             |  16 +-
 locales/index.d.ts                            |  56 +++++++
 locales/ja-JP.yml                             |  16 ++
 locales/ko-KR.yml                             |  16 +-
 packages/backend/src/config.ts                |  16 ++
 .../src/core/entities/MetaEntityService.ts    |   1 +
 .../backend/src/models/json-schema/meta.ts    |   4 +
 .../backend/src/server/api/EndpointsModule.ts |   4 +
 packages/backend/src/server/api/endpoints.ts  |   2 +
 .../api/endpoints/users/get-skeb-status.ts    | 148 ++++++++++++++++++
 packages/frontend/src/pages/user/home.vue     | 102 +++++++++++-
 packages/misskey-js/etc/misskey-js.api.md     |   8 +
 .../misskey-js/src/autogen/apiClientJSDoc.ts  |  11 ++
 packages/misskey-js/src/autogen/endpoint.ts   |   3 +
 packages/misskey-js/src/autogen/entities.ts   |   2 +
 packages/misskey-js/src/autogen/types.ts      |  82 ++++++++++
 16 files changed, 483 insertions(+), 4 deletions(-)
 create mode 100644 packages/backend/src/server/api/endpoints/users/get-skeb-status.ts

diff --git a/locales/en-US.yml b/locales/en-US.yml
index a44447a25f..f2ef64beb4 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -2575,4 +2575,18 @@ _reversi:
 _offlineScreen:
   title: "Offline - cannot connect to the server"
   header: "Unable to connect to the server"
-
+_skebStatus:
+  _genres:
+    art: "Artwork"
+    comic: "Comic"
+    voice: "Voice"
+    novel: "Text"
+    video: "Movie"
+    music: "Music"
+    correction: "Advice"
+  seeking: "Seeking"
+  stopped: "Stopped"
+  client: "Client"
+  yenX: "JPY {x}"
+  nWorks: "Delivered {n} works"
+  nRequests: "Requested {n} times"
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 70beffa0cf..ecce896b68 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -10113,6 +10113,62 @@ export interface Locale extends ILocale {
          */
         "summaryProxyDescription2": string;
     };
+    "_skebStatus": {
+        "_genres": {
+            /**
+             * イラスト
+             */
+            "art": string;
+            /**
+             * コミック
+             */
+            "comic": string;
+            /**
+             * ボイス
+             */
+            "voice": string;
+            /**
+             * テキスト
+             */
+            "novel": string;
+            /**
+             * ムービー
+             */
+            "video": string;
+            /**
+             * ミュージック
+             */
+            "music": string;
+            /**
+             * アドバイス
+             */
+            "correction": string;
+        };
+        /**
+         * 募集中
+         */
+        "seeking": string;
+        /**
+         * 停止中
+         */
+        "stopped": string;
+        /**
+         * クライアント
+         */
+        "client": string;
+        /**
+         * {x}円
+         */
+        "yenX": ParameterizedString<"x">;
+        /**
+         * 納品実績 {n}件
+         */
+        "nWorks": ParameterizedString<"n">;
+        /**
+         * 取引実績 {n}件
+         */
+        "nRequests": ParameterizedString<"n">;
+    };
 }
 declare const locales: {
     [lang: string]: Locale;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index ec4f688eb5..6ac6069596 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2693,3 +2693,19 @@ _urlPreviewSetting:
   summaryProxy: "プレビューを生成するプロキシのエンドポイント"
   summaryProxyDescription: "Misskey本体ではなく、サマリープロキシを使用してプレビューを生成します。"
   summaryProxyDescription2: "プロキシには下記パラメータがクエリ文字列として連携されます。プロキシ側がこれらをサポートしない場合、設定値は無視されます。"
+
+_skebStatus:
+  _genres:
+    art: "イラスト"
+    comic: "コミック"
+    voice: "ボイス"
+    novel: "テキスト"
+    video: "ムービー"
+    music: "ミュージック"
+    correction: "アドバイス"
+  seeking: "募集中"
+  stopped: "停止中"
+  client: "クライアント"
+  yenX: "{x}円"
+  nWorks: "納品実績 {n}件"
+  nRequests: "取引実績 {n}件"
diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml
index a736dcc23c..7f5fb8eb7d 100644
--- a/locales/ko-KR.yml
+++ b/locales/ko-KR.yml
@@ -2549,4 +2549,18 @@ _reversi:
 _offlineScreen:
   title: "오프라인 - 서버에 접속할 수 없습니다"
   header: "서버에 접속할 수 없습니다"
-
+_skebStatus:
+  _genres:
+    art: "작품"
+    comic: "만화"
+    voice: "음성"
+    novel: "텍스트"
+    video: "동영상"
+    music: "음악"
+    correction: "조언"
+  seeking: "모집 중"
+  stopped: "정지 중"
+  client: "클라이언트"
+  yenX: "JPY {x}"
+  nWorks: "납품 실적 {n}건"
+  nRequests: "거래 실적 {n}건"
diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index 04212e35c2..8502d1ef13 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -66,6 +66,14 @@ type Source = {
 		scope?: 'local' | 'global' | string[];
 	};
 
+	skebStatus?: {
+		method: string;
+		endpoint: string;
+		headers: { [x: string]: string };
+		parameters: { [x: string]: string };
+		userIdParameterName: string;
+	}
+
 	proxy?: string;
 	proxySmtp?: string;
 	proxyBypassHosts?: string[];
@@ -140,6 +148,13 @@ export type Config = {
 		index: string;
 		scope?: 'local' | 'global' | string[];
 	} | undefined;
+	skebStatus: {
+		method: string;
+		endpoint: string;
+		headers: { [x: string]: string };
+		parameters: { [x: string]: string };
+		userIdParameterName: string;
+	} | undefined;
 	proxy: string | undefined;
 	proxySmtp: string | undefined;
 	proxyBypassHosts: string[] | undefined;
@@ -266,6 +281,7 @@ export function loadConfig(): Config {
 		redisForObjectStorageQueue: config.redisForObjectStorageQueue ? convertRedisOptions(config.redisForObjectStorageQueue, host) : redisForJobQueue,
 		redisForWebhookDeliverQueue: config.redisForWebhookDeliverQueue ? convertRedisOptions(config.redisForWebhookDeliverQueue, host) : redisForJobQueue,
 		redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
+		skebStatus: config.skebStatus,
 		id: config.id,
 		proxy: config.proxy,
 		proxySmtp: config.proxySmtp,
diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts
index 27f0364dd7..7fbde9008f 100644
--- a/packages/backend/src/core/entities/MetaEntityService.ts
+++ b/packages/backend/src/core/entities/MetaEntityService.ts
@@ -112,6 +112,7 @@ export class MetaEntityService {
 
 			mediaProxy: this.config.mediaProxy,
 			enableUrlPreview: instance.urlPreviewEnabled,
+			enableSkebStatus: !!this.config.skebStatus,
 		};
 	}
 
diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts
index 633cc1cc0c..bff1461149 100644
--- a/packages/backend/src/models/json-schema/meta.ts
+++ b/packages/backend/src/models/json-schema/meta.ts
@@ -216,6 +216,10 @@ export const packedMetaLiteSchema = {
 			type: 'boolean',
 			optional: false, nullable: false,
 		},
+		enableSkebStatus: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
 		backgroundImageUrl: {
 			type: 'string',
 			optional: false, nullable: true,
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index 7842f0e28d..85a39548a4 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -350,6 +350,7 @@ import * as ep___users_following from './endpoints/users/following.js';
 import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js';
 import * as ep___users_getFollowingBirthdayUsers from './endpoints/users/get-following-birthday-users.js';
 import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js';
+import * as ep___users_getSkebStatus from './endpoints/users/get-skeb-status.js';
 import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js';
 import * as ep___users_lists_create from './endpoints/users/lists/create.js';
 import * as ep___users_lists_delete from './endpoints/users/lists/delete.js';
@@ -736,6 +737,7 @@ const $users_following: Provider = { provide: 'ep:users/following', useClass: ep
 const $users_gallery_posts: Provider = { provide: 'ep:users/gallery/posts', useClass: ep___users_gallery_posts.default };
 const $users_getFollowingBirthdayUsers: Provider = { provide: 'ep:users/get-following-birthday-users', useClass: ep___users_getFollowingBirthdayUsers.default };
 const $users_getFrequentlyRepliedUsers: Provider = { provide: 'ep:users/get-frequently-replied-users', useClass: ep___users_getFrequentlyRepliedUsers.default };
+const $users_getSkebStatus: Provider = { provide: 'ep:users/get-skeb-status', useClass: ep___users_getSkebStatus.default };
 const $users_featuredNotes: Provider = { provide: 'ep:users/featured-notes', useClass: ep___users_featuredNotes.default };
 const $users_lists_create: Provider = { provide: 'ep:users/lists/create', useClass: ep___users_lists_create.default };
 const $users_lists_delete: Provider = { provide: 'ep:users/lists/delete', useClass: ep___users_lists_delete.default };
@@ -1126,6 +1128,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
 		$users_gallery_posts,
 		$users_getFollowingBirthdayUsers,
 		$users_getFrequentlyRepliedUsers,
+		$users_getSkebStatus,
 		$users_featuredNotes,
 		$users_lists_create,
 		$users_lists_delete,
@@ -1508,6 +1511,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
 		$users_gallery_posts,
 		$users_getFollowingBirthdayUsers,
 		$users_getFrequentlyRepliedUsers,
+		$users_getSkebStatus,
 		$users_featuredNotes,
 		$users_lists_create,
 		$users_lists_delete,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index be8c4f0803..368ffdca76 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -350,6 +350,7 @@ import * as ep___users_following from './endpoints/users/following.js';
 import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js';
 import * as ep___users_getFollowingBirthdayUsers from './endpoints/users/get-following-birthday-users.js';
 import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js';
+import * as ep___users_getSkebStatus from './endpoints/users/get-skeb-status.js';
 import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js';
 import * as ep___users_lists_create from './endpoints/users/lists/create.js';
 import * as ep___users_lists_delete from './endpoints/users/lists/delete.js';
@@ -734,6 +735,7 @@ const eps = [
 	['users/gallery/posts', ep___users_gallery_posts],
 	['users/get-following-birthday-users', ep___users_getFollowingBirthdayUsers],
 	['users/get-frequently-replied-users', ep___users_getFrequentlyRepliedUsers],
+	['users/get-skeb-status', ep___users_getSkebStatus],
 	['users/featured-notes', ep___users_featuredNotes],
 	['users/lists/create', ep___users_lists_create],
 	['users/lists/delete', ep___users_lists_delete],
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
new file mode 100644
index 0000000000..79b23ae734
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/get-skeb-status.ts
@@ -0,0 +1,148 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { LoggerService } from '@/core/LoggerService.js';
+import { HttpRequestService } from '@/core/HttpRequestService.js';
+import type { Config } from '@/config.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['users'],
+
+	requireCredential: false,
+	allowGet: true,
+	cacheSec: 60 * 5,
+
+	res: {
+		type: 'object',
+		properties: {
+			screenName: {
+				type: 'string',
+				optional: false, nullable: false,
+			},
+			isCreator: {
+				type: 'boolean',
+				optional: false, nullable: false,
+			},
+			isAcceptable: {
+				type: 'boolean',
+				optional: false, nullable: false,
+			},
+			creatorRequestCount: {
+				type: 'integer',
+				optional: false, nullable: false,
+			},
+			clientRequestCount: {
+				type: 'integer',
+				optional: false, nullable: false,
+			},
+			skills: {
+				type: 'array',
+				optional: false, nullable: false,
+				items: {
+					type: 'object',
+					properties: {
+						amount: {
+							type: 'integer',
+							optional: false, nullable: false,
+						},
+						genre: {
+							type: 'string',
+							optional: false, nullable: false,
+							enum: ['art', 'comic', 'voice', 'novel', 'video', 'music', 'correction'],
+						},
+					},
+				},
+			},
+		},
+	},
+
+	errors: {
+		skebStatusNotAvailable: {
+			message: 'Skeb status is not available.',
+			code: 'SKEB_STATUS_NOT_AVAILABLE',
+			id: '1dd37c9c-7e97-4c24-be98-227a78b21d80',
+			httpStatusCode: 403,
+		},
+
+		noSuchUser: {
+			message: 'No such user.',
+			code: 'NO_SUCH_USER',
+			id: '88d582ae-69d9-45e0-a8b3-13f9945e48bf',
+			httpStatusCode: 404,
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		userId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['userId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		@Inject(DI.config)
+		private config: Config,
+
+		private loggerService: LoggerService,
+		private httpRequestService: HttpRequestService,
+	) {
+		super(meta, paramDef, async (ps) => {
+			if (!this.config.skebStatus) throw new ApiError(meta.errors.skebStatusNotAvailable);
+			const logger = this.loggerService.getLogger('api:users:get-skeb-status');
+
+			const url = new URL(this.config.skebStatus.endpoint);
+			for (const [key, value] of Object.entries(this.config.skebStatus.parameters)) {
+				url.searchParams.set(key, value);
+			}
+			url.searchParams.set(this.config.skebStatus.userIdParameterName, ps.userId);
+
+			logger.info('Requesting Skeb status', { url: url.href, userId: ps.userId });
+			const res = await this.httpRequestService.send(
+				url.href,
+				{
+					method: this.config.skebStatus.method,
+					headers: {
+						...this.config.skebStatus.headers,
+						Accept: 'application/json',
+					},
+					timeout: 5000,
+				},
+				{
+					throwErrorWhenResponseNotOk: false,
+				},
+			);
+
+			const json = (await res.json()) as {
+				screen_name: string,
+				is_creator: boolean,
+				is_acceptable: boolean,
+				creator_request_count: number,
+				client_request_count: number,
+				skills: { amount: number, genre: 'art' | 'comic' | 'voice' | 'novel' | 'video' | 'music' | 'correction' }[],
+				ban_reason?: string | null
+				error?: unknown,
+			};
+
+			if (res.status > 399 || (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 });
+				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 });
+
+			return {
+				screenName: json.screen_name,
+				isCreator: json.is_creator,
+				isAcceptable: json.is_acceptable,
+				creatorRequestCount: json.creator_request_count,
+				clientRequestCount: json.client_request_count,
+				skills: json.skills,
+			};
+		});
+	}
+}
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 82b9e38046..8ca02f699f 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -94,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 							<dd class="value">{{ dateString(user.createdAt) }} (<MkTime :time="user.createdAt"/>)</dd>
 						</dl>
 					</div>
-					<div v-if="user.fields.length > 0" class="fields">
+					<div v-if="user.fields.length > 0 || userSkebStatus" class="fields">
 						<dl v-for="(field, i) in user.fields" :key="i" class="field">
 							<dt class="name">
 								<Mfm :text="field.name" :plain="true" :colored="false"/>
@@ -104,6 +104,40 @@ SPDX-License-Identifier: AGPL-3.0-only
 								<i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ti ti-circle-check" :class="$style.verifiedLink"></i>
 							</dd>
 						</dl>
+						<dl v-if="userSkebStatus" class="field">
+							<dt class="name">
+								<a :href="`https://skeb.jp/@${userSkebStatus.screenName}`" target="_blank" rel="noopener">
+									<!--
+										*** LICENSE NOTICE ***
+										* This SVG is derived from the https://skeb.jp/ website, All rights reserved to *Skeb Inc.* https://skeb.co.jp/
+										* This resource SHOULD NOT be considered as a part of the this project that has licensed under AGPL-3.0-only
+									-->
+									<svg class="ti ti-fw" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
+										<linearGradient id="a" x1="14.645309%" x2="85.354691%" y1="14.645309%" y2="85.363492%">
+											<stop offset=".2" stop-color="#30b396"/>
+											<stop offset="1" stop-color="#1e5e71"/>
+										</linearGradient>
+										<g fill="none">
+											<circle cx="19.99375" cy="19.98125" fill="url(#a)" r="17.753125"/>
+											<path d="m18.784375 13.9875-7.35625 21.546875c-.8125-.446875-1.58125-.959375-2.309375-1.525l1.134375-3.26875-2.528125-.86875.859375-2.56875 2.528125.86875 1.728125-5.05625-2.528125-.86875.86875-2.5375 2.528125.86875 1.728125-5.05625-2.528125-.86875.86875-2.5375 2.528125.86875 1.6875-4.99375-2.496875-.909375.440625-1.26875 5.05625 1.728125-.378125 1.1-1.76875 5.184375-.059375.159375zm-12.996875 16.640625c.6625.884375 1.40625 1.703125 2.21875 2.446875l.51875-1.515625zm3.29375-13.01875 3.8 1.296875.865625-2.534375-3.8-1.296875zm-2.590625 7.590625 3.8 1.296875.865625-2.534375-3.8-1.296875zm31.259375-5.21875c0-2.534375-.534375-4.940625-1.490625-7.11875l-13.325-4.559375-9.603125 28.134375c2.059375.834375 4.30625 1.3 6.665625 1.3 9.80625 0 17.753125-7.946875 17.753125-17.753125zm-26.071875-9.971875 3.8 1.296875.865625-2.534375-3.8-1.296875z" fill="#fff"/><path d="m19.99375 2.23125c9.80625 0 17.753125 7.946875 17.753125 17.753125s-7.946875 17.753125-17.753125 17.753125-17.753125-7.946875-17.753125-17.753125 7.946875-17.753125 17.753125-17.753125m0-2.228125c-11.034375 0-19.98125 8.946875-19.98125 19.98125s8.946875 19.98125 19.98125 19.98125 19.98125-8.946875 19.98125-19.98125-8.946875-19.98125-19.98125-19.98125z" fill="#1e5e71"/>
+										</g>
+									</svg>
+									Skeb
+								</a>
+							</dt>
+							<dd class="value" style="display: flex; gap: 8px; align-items: center;">
+								<span v-if="userSkebStatus.isAcceptable" :class="$style.skebAcceptable">
+									{{ i18n.ts._skebStatus.seeking }}
+								</span>
+								<span v-else-if="userSkebStatus.isCreator" :class="$style.skebStopped">
+									{{ i18n.ts._skebStatus.stopped }}
+								</span>
+								<span v-else :class="$style.skebClient">
+									{{ i18n.ts._skebStatus.client }}
+								</span>
+								<Mfm :text="buildSkebStatus()" :author="user" :nyaize="false" :colored="false"/>
+							</dd>
+						</dl>
 					</div>
 					<div class="status">
 						<MkA :to="userPage(user)">
@@ -167,10 +201,11 @@ import number from '@/filters/number.js';
 import { userPage } from '@/filters/user.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
+import { instance } from '@/instance.js';
 import { $i, iAmModerator } from '@/account.js';
 import { dateString } from '@/filters/date.js';
 import { confetti } from '@/scripts/confetti.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
 import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js';
 import { useRouter } from '@/router/supplier.js';
 
@@ -204,6 +239,7 @@ const props = withDefaults(defineProps<{
 const router = useRouter();
 
 const user = ref(props.user);
+const userSkebStatus = ref<Misskey.Endpoints['users/get-skeb-status']['res'] | null>(null);
 const parallaxAnimationId = ref<null | number>(null);
 const narrow = ref<null | boolean>(null);
 const rootEl = ref<null | HTMLElement>(null);
@@ -273,8 +309,44 @@ async function updateMemo() {
 	isEditingMemo.value = false;
 }
 
+async function fetchSkebStatus() {
+	if (!instance.enableSkebStatus || !props.user.id) {
+		userSkebStatus.value = null;
+		return;
+	}
+
+	console.log('fetching skeb status');
+	userSkebStatus.value = await misskeyApiGet('users/get-skeb-status', { userId: props.user.id });
+}
+
+function buildSkebStatus(): string {
+	if (!userSkebStatus.value) return '';
+
+	if (userSkebStatus.value.isCreator) {
+		let status = '';
+
+		if (userSkebStatus.value.isAcceptable) {
+			status += `${i18n.ts._skebStatus._genres[userSkebStatus.value.skills[0].genre]} ${i18n.tsx._skebStatus.yenX({ x: userSkebStatus.value.skills[0].amount.toLocaleString() })}`;
+		}
+
+		if (userSkebStatus.value.creatorRequestCount > 0) {
+			if (userSkebStatus.value.isAcceptable) {
+				status += ' | ';
+			}
+			status += i18n.tsx._skebStatus.nWorks({ n: userSkebStatus.value.creatorRequestCount.toLocaleString() });
+		}
+
+		return status;
+	} else if (userSkebStatus.value.clientRequestCount > 0) {
+		return i18n.tsx._skebStatus.nRequests({ n: userSkebStatus.value.clientRequestCount.toLocaleString() });
+	}
+
+	return '';
+}
+
 watch([props.user], () => {
 	memoDraft.value = props.user.memo;
+	fetchSkebStatus();
 });
 
 onMounted(() => {
@@ -292,6 +364,7 @@ onMounted(() => {
 			});
 		}
 	}
+	fetchSkebStatus();
 	nextTick(() => {
 		adjustMemoTextarea();
 	});
@@ -685,4 +758,29 @@ onUnmounted(() => {
 	margin-left: 4px;
 	color: var(--success);
 }
+
+.skebAcceptable,
+.skebStopped,
+.skebClient {
+	display: inline-flex;
+	border: solid 1px;
+	border-radius: 6px;
+	padding: 2px 6px;
+	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/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 3289b4f7cf..e5f23163c1 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1690,6 +1690,8 @@ declare namespace entities {
         UsersGetFollowingBirthdayUsersResponse,
         UsersGetFrequentlyRepliedUsersRequest,
         UsersGetFrequentlyRepliedUsersResponse,
+        UsersGetSkebStatusRequest,
+        UsersGetSkebStatusResponse,
         UsersFeaturedNotesRequest,
         UsersFeaturedNotesResponse,
         UsersListsCreateRequest,
@@ -3105,6 +3107,12 @@ type UsersGetFrequentlyRepliedUsersRequest = operations['users___get-frequently-
 // @public (undocumented)
 type UsersGetFrequentlyRepliedUsersResponse = operations['users___get-frequently-replied-users']['responses']['200']['content']['application/json'];
 
+// @public (undocumented)
+type UsersGetSkebStatusRequest = operations['users___get-skeb-status']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type UsersGetSkebStatusResponse = operations['users___get-skeb-status']['responses']['200']['content']['application/json'];
+
 // @public (undocumented)
 type UsersListsCreateFromPublicRequest = operations['users___lists___create-from-public']['requestBody']['content']['application/json'];
 
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index 35d746190f..f25943a4f2 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -3826,6 +3826,17 @@ declare module '../api.js' {
       credential?: string | null,
     ): Promise<SwitchCaseResponseType<E, P>>;
 
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *No*
+     */
+    request<E extends 'users/get-skeb-status', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
     /**
      * No description provided.
      * 
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index 2dd65df9a4..6ddc534ea5 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -510,6 +510,8 @@ import type {
 	UsersGetFollowingBirthdayUsersResponse,
 	UsersGetFrequentlyRepliedUsersRequest,
 	UsersGetFrequentlyRepliedUsersResponse,
+	UsersGetSkebStatusRequest,
+	UsersGetSkebStatusResponse,
 	UsersFeaturedNotesRequest,
 	UsersFeaturedNotesResponse,
 	UsersListsCreateRequest,
@@ -920,6 +922,7 @@ export type Endpoints = {
 	'users/gallery/posts': { req: UsersGalleryPostsRequest; res: UsersGalleryPostsResponse };
 	'users/get-following-birthday-users': { req: UsersGetFollowingBirthdayUsersRequest; res: UsersGetFollowingBirthdayUsersResponse };
 	'users/get-frequently-replied-users': { req: UsersGetFrequentlyRepliedUsersRequest; res: UsersGetFrequentlyRepliedUsersResponse };
+	'users/get-skeb-status': { req: UsersGetSkebStatusRequest; res: UsersGetSkebStatusResponse };
 	'users/featured-notes': { req: UsersFeaturedNotesRequest; res: UsersFeaturedNotesResponse };
 	'users/lists/create': { req: UsersListsCreateRequest; res: UsersListsCreateResponse };
 	'users/lists/delete': { req: UsersListsDeleteRequest; res: EmptyResponse };
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index 2c9afc650d..bc5b420a43 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -513,6 +513,8 @@ export type UsersGetFollowingBirthdayUsersRequest = operations['users___get-foll
 export type UsersGetFollowingBirthdayUsersResponse = operations['users___get-following-birthday-users']['responses']['200']['content']['application/json'];
 export type UsersGetFrequentlyRepliedUsersRequest = operations['users___get-frequently-replied-users']['requestBody']['content']['application/json'];
 export type UsersGetFrequentlyRepliedUsersResponse = operations['users___get-frequently-replied-users']['responses']['200']['content']['application/json'];
+export type UsersGetSkebStatusRequest = operations['users___get-skeb-status']['requestBody']['content']['application/json'];
+export type UsersGetSkebStatusResponse = operations['users___get-skeb-status']['responses']['200']['content']['application/json'];
 export type UsersFeaturedNotesRequest = operations['users___featured-notes']['requestBody']['content']['application/json'];
 export type UsersFeaturedNotesResponse = operations['users___featured-notes']['responses']['200']['content']['application/json'];
 export type UsersListsCreateRequest = operations['users___lists___create']['requestBody']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 83250ce4ae..398da6bbe9 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -3294,6 +3294,22 @@ export type paths = {
      */
     post: operations['users___get-frequently-replied-users'];
   };
+  '/users/get-skeb-status': {
+    /**
+     * users/get-skeb-status
+     * @description No description provided.
+     *
+     * **Credential required**: *No*
+     */
+    get: operations['users___get-skeb-status'];
+    /**
+     * users/get-skeb-status
+     * @description No description provided.
+     *
+     * **Credential required**: *No*
+     */
+    post: operations['users___get-skeb-status'];
+  };
   '/users/featured-notes': {
     /**
      * users/featured-notes
@@ -5008,6 +5024,7 @@ export type components = {
       translatorAvailable: boolean;
       mediaProxy: string;
       enableUrlPreview: boolean;
+      enableSkebStatus: boolean;
       backgroundImageUrl: string | null;
       impressumUrl: string | null;
       logoImageUrl: string | null;
@@ -25669,6 +25686,71 @@ export type operations = {
       };
     };
   };
+  /**
+   * users/get-skeb-status
+   * @description No description provided.
+   *
+   * **Credential required**: *No*
+   */
+  'users___get-skeb-status': {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          userId: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': {
+            screenName: string;
+            isCreator: boolean;
+            isAcceptable: boolean;
+            creatorRequestCount: number;
+            clientRequestCount: number;
+            skills: ({
+                amount: number;
+                /** @enum {string} */
+                genre: 'art' | 'comic' | 'voice' | 'novel' | 'video' | 'music' | 'correction';
+              })[];
+          };
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
   /**
    * users/featured-notes
    * @description No description provided.