From 8959bfa1c0b558888aa7da207f8166092c51a353 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Tue, 6 May 2025 14:41:31 +0900
Subject: [PATCH] =?UTF-8?q?refactor(frontend):=20=E7=A9=BA/=E3=82=A8?=
 =?UTF-8?q?=E3=83=A9=E3=83=BC=E7=B5=90=E6=9E=9C=E8=A1=A8=E7=A4=BA=E3=82=92?=
 =?UTF-8?q?=E3=82=B3=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=8D=E3=83=B3=E3=83=88?=
 =?UTF-8?q?=E5=8C=96=20(#15963)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* wip

* wip

* wip

* wip

* wip

* Update MkResult.vue

* Add storybook story for MkResult (#15964)

* Update MkResult.vue

---------

Co-authored-by: taichan <40626578+tai-cha@users.noreply.github.com>
---
 .../frontend-embed/src/pages/not-found.vue    |  4 --
 packages/frontend-embed/src/style.scss        |  7 ---
 packages/frontend-shared/js/const.ts          |  4 --
 .../frontend/src/components/MkChannelList.vue |  8 +--
 .../src/components/MkChatHistories.vue        |  4 +-
 .../frontend/src/components/MkFormDialog.vue  |  6 +-
 packages/frontend/src/components/MkNotes.vue  |  8 +--
 .../src/components/MkNotification.vue         |  2 -
 .../src/components/MkNotifications.vue        |  8 +--
 .../frontend/src/components/MkPagination.vue  |  8 +--
 .../frontend/src/components/MkTimeline.vue    |  8 +--
 .../frontend/src/components/MkUserList.vue    |  8 +--
 .../src/components/global/MkError.vue         |  4 +-
 .../global/MkResult.stories.impl.ts           | 57 +++++++++++++++++++
 .../src/components/global/MkResult.vue        | 44 ++++++++++++++
 packages/frontend/src/components/index.ts     |  3 +
 packages/frontend/src/instance.ts             |  7 ---
 packages/frontend/src/pages/_error_.vue       |  4 +-
 .../frontend/src/pages/admin/roles.role.vue   |  8 +--
 .../src/pages/chat/home.invitations.vue       |  4 +-
 .../src/pages/chat/home.joiningRooms.vue      |  4 +-
 .../src/pages/chat/home.ownedRooms.vue        |  4 +-
 .../frontend/src/pages/chat/room.search.vue   |  6 +-
 .../frontend/src/pages/drive.file.info.vue    |  6 +-
 packages/frontend/src/pages/favorites.vue     |  8 +--
 .../frontend/src/pages/follow-requests.vue    |  8 +--
 packages/frontend/src/pages/invite.vue        | 30 +---------
 packages/frontend/src/pages/list.vue          |  9 +--
 .../frontend/src/pages/my-antennas/index.vue  |  8 +--
 .../frontend/src/pages/my-lists/index.vue     |  8 +--
 packages/frontend/src/pages/not-found.vue     |  6 +-
 packages/frontend/src/pages/role.vue          | 41 +------------
 packages/frontend/src/pages/settings/apps.vue |  8 +--
 .../src/pages/settings/mute-block.vue         | 23 ++------
 packages/frontend/src/style.scss              | 12 ----
 .../src/widgets/WidgetBirthdayFollowings.vue  | 12 +---
 packages/frontend/src/widgets/WidgetRss.vue   |  6 +-
 37 files changed, 140 insertions(+), 265 deletions(-)
 create mode 100644 packages/frontend/src/components/global/MkResult.stories.impl.ts
 create mode 100644 packages/frontend/src/components/global/MkResult.vue

diff --git a/packages/frontend-embed/src/pages/not-found.vue b/packages/frontend-embed/src/pages/not-found.vue
index 061254a39a..68897ca7e1 100644
--- a/packages/frontend-embed/src/pages/not-found.vue
+++ b/packages/frontend-embed/src/pages/not-found.vue
@@ -6,7 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <div>
 	<div class="_fullinfo">
-		<img :src="notFoundImageUrl" draggable="false"/>
 		<div>{{ i18n.ts.notFoundDescription }}</div>
 	</div>
 </div>
@@ -14,11 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { inject, computed } from 'vue';
-import { DEFAULT_NOT_FOUND_IMAGE_URL } from '@@/js/const.js';
 import { DI } from '@/di.js';
 import { i18n } from '@/i18n.js';
 
 const serverMetadata = inject(DI.serverMetadata)!;
-
-const notFoundImageUrl = computed(() => serverMetadata.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL);
 </script>
diff --git a/packages/frontend-embed/src/style.scss b/packages/frontend-embed/src/style.scss
index b67f929933..035d687ee4 100644
--- a/packages/frontend-embed/src/style.scss
+++ b/packages/frontend-embed/src/style.scss
@@ -286,13 +286,6 @@ rt {
 ._fullinfo {
 	padding: 64px 32px;
 	text-align: center;
-
-	> img {
-		vertical-align: bottom;
-		height: 128px;
-		margin-bottom: 16px;
-		border-radius: 16px;
-	}
 }
 
 ._link {
diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts
index 84b5afe78f..8c49b41f4d 100644
--- a/packages/frontend-shared/js/const.ts
+++ b/packages/frontend-shared/js/const.ts
@@ -112,10 +112,6 @@ export const ROLE_POLICIES = [
 	'chatAvailability',
 ] as const;
 
-export const DEFAULT_SERVER_ERROR_IMAGE_URL = 'https://xn--931a.moe/assets/error.jpg';
-export const DEFAULT_NOT_FOUND_IMAGE_URL = 'https://xn--931a.moe/assets/not-found.jpg';
-export const DEFAULT_INFO_IMAGE_URL = 'https://xn--931a.moe/assets/info.jpg';
-
 export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime'];
 export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {
 	tada: ['speed=', 'delay='],
diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue
index fdb7d2a1c4..d0b50f04f2 100644
--- a/packages/frontend/src/components/MkChannelList.vue
+++ b/packages/frontend/src/components/MkChannelList.vue
@@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <MkPagination :pagination="pagination">
-	<template #empty>
-		<div class="_fullinfo">
-			<img :src="infoImageUrl" draggable="false"/>
-			<div>{{ i18n.ts.notFound }}</div>
-		</div>
-	</template>
+	<template #empty><MkResult type="empty"/></template>
 
 	<template #default="{ items }">
 		<MkChannelPreview v-for="item in items" :key="item.id" class="_margin" :channel="extractor(item)"/>
@@ -23,7 +18,6 @@ import type { Paging } from '@/components/MkPagination.vue';
 import MkChannelPreview from '@/components/MkChannelPreview.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import { i18n } from '@/i18n.js';
-import { infoImageUrl } from '@/instance.js';
 
 const props = withDefaults(defineProps<{
 	pagination: Paging;
diff --git a/packages/frontend/src/components/MkChatHistories.vue b/packages/frontend/src/components/MkChatHistories.vue
index c508ea8451..b33ed428c7 100644
--- a/packages/frontend/src/components/MkChatHistories.vue
+++ b/packages/frontend/src/components/MkChatHistories.vue
@@ -28,9 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</div>
 	</MkA>
 </div>
-<div v-if="!initializing && history.length == 0" class="_fullinfo">
-	<div>{{ i18n.ts._chat.noHistory }}</div>
-</div>
+<MkResult v-if="!initializing && history.length == 0" type="empty" :text="i18n.ts._chat.noHistory"/>
 <MkLoading v-if="initializing"/>
 </template>
 
diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
index 0884cdc016..6ac4441cac 100644
--- a/packages/frontend/src/components/MkFormDialog.vue
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -62,10 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				/>
 			</template>
 		</div>
-		<div v-else class="_fullinfo">
-			<img :src="infoImageUrl" draggable="false"/>
-			<div>{{ i18n.ts.nothing }}</div>
-		</div>
+		<MkResult v-else type="empty"/>
 	</div>
 </MkModalWindow>
 </template>
@@ -83,7 +80,6 @@ import XFile from './MkFormDialog.file.vue';
 import type { Form } from '@/utility/form.js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import { i18n } from '@/i18n.js';
-import { infoImageUrl } from '@/instance.js';
 
 const props = defineProps<{
 	title: string;
diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue
index 9d862a4eac..509099e0b9 100644
--- a/packages/frontend/src/components/MkNotes.vue
+++ b/packages/frontend/src/components/MkNotes.vue
@@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad">
-	<template #empty>
-		<div class="_fullinfo">
-			<img :src="infoImageUrl" draggable="false"/>
-			<div>{{ i18n.ts.noNotes }}</div>
-		</div>
-	</template>
+	<template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
 
 	<template #default="{ items: notes }">
 		<div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]">
@@ -34,7 +29,6 @@ import type { Paging } from '@/components/MkPagination.vue';
 import MkNote from '@/components/MkNote.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import { i18n } from '@/i18n.js';
-import { infoImageUrl } from '@/instance.js';
 
 const props = defineProps<{
 	pagination: Paging;
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 9672efca0a..21104b41df 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -11,7 +11,6 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
 		<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
 		<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
-		<img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/>
 		<MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/>
 		<img v-else-if="'icon' in notification && notification.icon != null" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
 		<div
@@ -176,7 +175,6 @@ import { userPage } from '@/filters/user.js';
 import { i18n } from '@/i18n.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { ensureSignin } from '@/i.js';
-import { infoImageUrl } from '@/instance.js';
 
 const $i = ensureSignin();
 
diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue
index 308a077bd9..3c88b8af0d 100644
--- a/packages/frontend/src/components/MkNotifications.vue
+++ b/packages/frontend/src/components/MkNotifications.vue
@@ -6,12 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()">
 	<MkPagination ref="pagingComponent" :pagination="pagination">
-		<template #empty>
-			<div class="_fullinfo">
-				<img :src="infoImageUrl" draggable="false"/>
-				<div>{{ i18n.ts.noNotifications }}</div>
-			</div>
-		</template>
+		<template #empty><MkResult type="empty" :text="i18n.ts.noNotifications"/></template>
 
 		<template #default="{ items: notifications }">
 			<component
@@ -42,7 +37,6 @@ import XNotification from '@/components/MkNotification.vue';
 import MkNote from '@/components/MkNote.vue';
 import { useStream } from '@/stream.js';
 import { i18n } from '@/i18n.js';
-import { infoImageUrl } from '@/instance.js';
 import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 9adc3d98da..54da5a889d 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -16,12 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<MkError v-else-if="error" @retry="init()"/>
 
 	<div v-else-if="empty" key="_empty_">
-		<slot name="empty">
-			<div class="_fullinfo">
-				<img :src="infoImageUrl" draggable="false"/>
-				<div>{{ i18n.ts.nothing }}</div>
-			</div>
-		</slot>
+		<slot name="empty"><MkResult type="empty"/></slot>
 	</div>
 
 	<div v-else ref="rootEl" class="_gaps">
@@ -88,7 +83,6 @@ function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): M
 
 </script>
 <script lang="ts" setup>
-import { infoImageUrl } from '@/instance.js';
 import MkButton from '@/components/MkButton.vue';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index e2c261787b..6a265aa836 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -6,12 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()">
 	<MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)">
-		<template #empty>
-			<div class="_fullinfo">
-				<img :src="infoImageUrl" draggable="false"/>
-				<div>{{ i18n.ts.noNotes }}</div>
-			</div>
-		</template>
+		<template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
 
 		<template #default="{ items: notes }">
 			<component
@@ -53,7 +48,6 @@ import { prefer } from '@/preferences.js';
 import MkNote from '@/components/MkNote.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import { i18n } from '@/i18n.js';
-import { infoImageUrl } from '@/instance.js';
 
 const props = withDefaults(defineProps<{
 	src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue
index 0d1ffd715f..90087cb000 100644
--- a/packages/frontend/src/components/MkUserList.vue
+++ b/packages/frontend/src/components/MkUserList.vue
@@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <MkPagination :pagination="pagination">
-	<template #empty>
-		<div class="_fullinfo">
-			<img :src="infoImageUrl" draggable="false"/>
-			<div>{{ i18n.ts.noUsers }}</div>
-		</div>
-	</template>
+	<template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template>
 
 	<template #default="{ items }">
 		<div :class="$style.root">
@@ -25,7 +20,6 @@ import type { Paging } from '@/components/MkPagination.vue';
 import MkUserInfo from '@/components/MkUserInfo.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import { i18n } from '@/i18n.js';
-import { infoImageUrl } from '@/instance.js';
 
 const props = withDefaults(defineProps<{
 	pagination: Paging;
diff --git a/packages/frontend/src/components/global/MkError.vue b/packages/frontend/src/components/global/MkError.vue
index 95ed255189..bc3a282e40 100644
--- a/packages/frontend/src/components/global/MkError.vue
+++ b/packages/frontend/src/components/global/MkError.vue
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear>
 	<div :class="$style.root">
-		<img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/>
+		<img v-if="instance.serverErrorImageUrl" :class="$style.img" :src="instance.serverErrorImageUrl" draggable="false"/>
 		<p :class="$style.text"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p>
 		<MkButton :class="$style.button" @click="() => emit('retry')">{{ i18n.ts.retry }}</MkButton>
 	</div>
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
 import { prefer } from '@/preferences.js';
-import { serverErrorImageUrl } from '@/instance.js';
+import { instance } from '@/instance.js';
 
 const emit = defineEmits<{
 	(ev: 'retry'): void;
diff --git a/packages/frontend/src/components/global/MkResult.stories.impl.ts b/packages/frontend/src/components/global/MkResult.stories.impl.ts
new file mode 100644
index 0000000000..05f8c9069b
--- /dev/null
+++ b/packages/frontend/src/components/global/MkResult.stories.impl.ts
@@ -0,0 +1,57 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import MkResult from './MkResult.vue';
+import type { StoryObj } from '@storybook/vue3';
+export const Default = {
+	render(args) {
+		return {
+			components: {
+				MkResult,
+			},
+			setup() {
+				return {
+					args,
+				};
+			},
+			computed: {
+				props() {
+					return {
+						...this.args,
+					};
+				},
+			},
+			template: '<MkResult v-bind="props" />',
+		};
+	},
+	args: {
+		type: 'empty',
+		text: 'Lorem Ipsum',
+	},
+	parameters: {
+		layout: 'centered',
+	},
+} satisfies StoryObj<typeof MkResult>;
+export const emptyWithNoText = {
+	...Default,
+	args: {
+		...Default.args,
+		text: undefined,
+	},
+} satisfies StoryObj<typeof MkResult>;
+export const notFound = {
+	...Default,
+	args: {
+		...Default.args,
+		type: 'notFound',
+	},
+} satisfies StoryObj<typeof MkResult>;
+export const errorType = {
+	...Default,
+	args: {
+		...Default.args,
+		type: 'error',
+	},
+} satisfies StoryObj<typeof MkResult>;
diff --git a/packages/frontend/src/components/global/MkResult.vue b/packages/frontend/src/components/global/MkResult.vue
new file mode 100644
index 0000000000..51cf8a860a
--- /dev/null
+++ b/packages/frontend/src/components/global/MkResult.vue
@@ -0,0 +1,44 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="[$style.root]" class="_gaps">
+	<img v-if="type === 'empty' && instance.infoImageUrl" :src="instance.infoImageUrl" draggable="false" :class="$style.img"/>
+	<i v-else-if="type === 'empty'" class="ti ti-info-circle" :class="$style.icon"></i>
+	<div>{{ props.text ?? (type === 'empty' ? i18n.ts.nothing : type === 'notFound' ? i18n.ts.notFound : null) }}</div>
+	<slot></slot>
+</div>
+</template>
+
+<script lang="ts" setup>
+import {} from 'vue';
+import { instance } from '@/instance.js';
+import { i18n } from '@/i18n.js';
+
+const props = defineProps<{
+	type: 'empty' | 'notFound' | 'error';
+	text?: string;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+	position: relative;
+	text-align: center;
+	padding: 32px;
+}
+
+.img {
+	vertical-align: bottom;
+	height: 128px;
+	margin-bottom: 16px;
+	border-radius: 16px;
+}
+
+.icon {
+	font-size: 24px;
+	margin: 0 auto;
+}
+</style>
diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts
index ec6ea7c569..33d3532c1d 100644
--- a/packages/frontend/src/components/index.ts
+++ b/packages/frontend/src/components/index.ts
@@ -24,6 +24,7 @@ import MkAd from './global/MkAd.vue';
 import MkPageHeader from './global/MkPageHeader.vue';
 import MkStickyContainer from './global/MkStickyContainer.vue';
 import MkLazy from './global/MkLazy.vue';
+import MkResult from './global/MkResult.vue';
 import PageWithHeader from './global/PageWithHeader.vue';
 import PageWithAnimBg from './global/PageWithAnimBg.vue';
 import SearchMarker from './global/SearchMarker.vue';
@@ -61,6 +62,7 @@ export const components = {
 	MkPageHeader: MkPageHeader,
 	MkStickyContainer: MkStickyContainer,
 	MkLazy: MkLazy,
+	MkResult: MkResult,
 	PageWithHeader: PageWithHeader,
 	PageWithAnimBg: PageWithAnimBg,
 	SearchMarker: SearchMarker,
@@ -92,6 +94,7 @@ declare module '@vue/runtime-core' {
 		MkPageHeader: typeof MkPageHeader;
 		MkStickyContainer: typeof MkStickyContainer;
 		MkLazy: typeof MkLazy;
+		MkResult: typeof MkResult;
 		PageWithHeader: typeof PageWithHeader;
 		PageWithAnimBg: typeof PageWithAnimBg;
 		SearchMarker: typeof SearchMarker;
diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts
index e75e3dfd34..2943e60e43 100644
--- a/packages/frontend/src/instance.ts
+++ b/packages/frontend/src/instance.ts
@@ -7,7 +7,6 @@ import { computed, reactive } from 'vue';
 import * as Misskey from 'misskey-js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { miLocalStorage } from '@/local-storage.js';
-import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERROR_IMAGE_URL } from '@@/js/const.js';
 
 // TODO: 他のタブと永続化されたstateを同期
 
@@ -30,12 +29,6 @@ if (providedAt > cachedAt) {
 
 export const instance: Misskey.entities.MetaDetailed = reactive(cachedMeta ?? {});
 
-export const serverErrorImageUrl = computed(() => instance.serverErrorImageUrl ?? DEFAULT_SERVER_ERROR_IMAGE_URL);
-
-export const infoImageUrl = computed(() => instance.infoImageUrl ?? DEFAULT_INFO_IMAGE_URL);
-
-export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL);
-
 export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true);
 
 export async function fetchInstance(force = false): Promise<Misskey.entities.MetaDetailed> {
diff --git a/packages/frontend/src/pages/_error_.vue b/packages/frontend/src/pages/_error_.vue
index 791267f5ca..d656f93fa3 100644
--- a/packages/frontend/src/pages/_error_.vue
+++ b/packages/frontend/src/pages/_error_.vue
@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <MkLoading v-if="!loaded"/>
 <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear>
 	<div v-show="loaded" :class="$style.root">
-		<img :src="serverErrorImageUrl" draggable="false" :class="$style.img"/>
+		<img v-if="instance.serverErrorImageUrl" :src="instance.serverErrorImageUrl" draggable="false" :class="$style.img"/>
 		<div class="_gaps">
 			<div><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></div>
 			<div v-if="meta && (version === meta.version)">{{ i18n.ts.pageLoadErrorDescription }}</div>
@@ -36,7 +36,7 @@ import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { prefer } from '@/preferences.js';
-import { serverErrorImageUrl } from '@/instance.js';
+import { instance } from '@/instance.js';
 
 const props = withDefaults(defineProps<{
 	error?: Error;
diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue
index 69645957bf..61d72777b8 100644
--- a/packages/frontend/src/pages/admin/roles.role.vue
+++ b/packages/frontend/src/pages/admin/roles.role.vue
@@ -24,12 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<MkButton primary rounded @click="assign"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
 
 					<MkPagination :pagination="usersPagination">
-						<template #empty>
-							<div class="_fullinfo">
-								<img :src="infoImageUrl" draggable="false"/>
-								<div>{{ i18n.ts.noUsers }}</div>
-							</div>
-						</template>
+						<template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template>
 
 						<template #default="{ items }">
 							<div class="_gaps_s">
@@ -70,7 +65,6 @@ import MkButton from '@/components/MkButton.vue';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkPagination from '@/components/MkPagination.vue';
-import { infoImageUrl } from '@/instance.js';
 import { useRouter } from '@/router.js';
 
 const router = useRouter();
diff --git a/packages/frontend/src/pages/chat/home.invitations.vue b/packages/frontend/src/pages/chat/home.invitations.vue
index 82b22ea9dd..3cbe186e9d 100644
--- a/packages/frontend/src/pages/chat/home.invitations.vue
+++ b/packages/frontend/src/pages/chat/home.invitations.vue
@@ -27,9 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</div>
 		</MkFolder>
 	</div>
-	<div v-if="!fetching && invitations.length == 0" class="_fullinfo">
-		<div>{{ i18n.ts._chat.noInvitations }}</div>
-	</div>
+	<MkResult v-if="!fetching && invitations.length == 0" type="empty" :text="i18n.ts._chat.noInvitations"/>
 	<MkLoading v-if="fetching"/>
 </div>
 </template>
diff --git a/packages/frontend/src/pages/chat/home.joiningRooms.vue b/packages/frontend/src/pages/chat/home.joiningRooms.vue
index f9fd6bfd55..8887aec3d5 100644
--- a/packages/frontend/src/pages/chat/home.joiningRooms.vue
+++ b/packages/frontend/src/pages/chat/home.joiningRooms.vue
@@ -8,9 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div v-if="memberships.length > 0" class="_gaps_s">
 		<XRoom v-for="membership in memberships" :key="membership.id" :room="membership.room!"/>
 	</div>
-	<div v-if="!fetching && memberships.length == 0" class="_fullinfo">
-		<div>{{ i18n.ts._chat.noRooms }}</div>
-	</div>
+	<MkResult v-if="!fetching && memberships.length == 0" type="empty" :text="i18n.ts._chat.noRooms"/>
 	<MkLoading v-if="fetching"/>
 </div>
 </template>
diff --git a/packages/frontend/src/pages/chat/home.ownedRooms.vue b/packages/frontend/src/pages/chat/home.ownedRooms.vue
index ce7da15563..9a7ae5dd72 100644
--- a/packages/frontend/src/pages/chat/home.ownedRooms.vue
+++ b/packages/frontend/src/pages/chat/home.ownedRooms.vue
@@ -8,9 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div v-if="rooms.length > 0" class="_gaps_s">
 		<XRoom v-for="room in rooms" :key="room.id" :room="room"/>
 	</div>
-	<div v-if="!fetching && rooms.length == 0" class="_fullinfo">
-		<div>{{ i18n.ts._chat.noRooms }}</div>
-	</div>
+	<MkResult v-if="!fetching && rooms.length == 0" type="empty" :text="i18n.ts._chat.noRooms"/>
 	<MkLoading v-if="fetching"/>
 </div>
 </template>
diff --git a/packages/frontend/src/pages/chat/room.search.vue b/packages/frontend/src/pages/chat/room.search.vue
index 20b6e22a46..1e4eaf5639 100644
--- a/packages/frontend/src/pages/chat/room.search.vue
+++ b/packages/frontend/src/pages/chat/room.search.vue
@@ -24,10 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<XMessage :message="message" :user="message.fromUser" :isSearchResult="true"/>
 			</div>
 		</div>
-		<div v-else class="_fullinfo">
-			<img :src="infoImageUrl" draggable="false"/>
-			<div>{{ i18n.ts.notFound }}</div>
-		</div>
+		<MkResult v-else type="notFound"/>
 	</MkFoldableSection>
 </div>
 </template>
@@ -38,7 +35,6 @@ import * as Misskey from 'misskey-js';
 import XMessage from './XMessage.vue';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
-import { infoImageUrl } from '@/instance.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import MkInput from '@/components/MkInput.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue
index 5390a48be5..21be0b18a9 100644
--- a/packages/frontend/src/pages/drive.file.info.vue
+++ b/packages/frontend/src/pages/drive.file.info.vue
@@ -68,10 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</MkKeyValue>
 		</div>
 	</div>
-	<div v-else class="_fullinfo">
-		<img :src="infoImageUrl" draggable="false"/>
-		<div>{{ i18n.ts.nothing }}</div>
-	</div>
+	<MkResult v-else type="empty"/>
 </div>
 </template>
 
@@ -82,7 +79,6 @@ import MkInfo from '@/components/MkInfo.vue';
 import MkMediaList from '@/components/MkMediaList.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
 import bytes from '@/filters/bytes.js';
-import { infoImageUrl } from '@/instance.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue
index 4f57c1209e..b0a18987b4 100644
--- a/packages/frontend/src/pages/favorites.vue
+++ b/packages/frontend/src/pages/favorites.vue
@@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <PageWithHeader>
 	<div class="_spacer" style="--MI_SPACER-w: 800px;">
 		<MkPagination :pagination="pagination">
-			<template #empty>
-				<div class="_fullinfo">
-					<img :src="infoImageUrl" draggable="false"/>
-					<div>{{ i18n.ts.noNotes }}</div>
-				</div>
-			</template>
+			<template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
 
 			<template #default="{ items }">
 				<MkDateSeparatedList v-slot="{ item }" :items="items" :direction="'down'" :noGap="false" :ad="false">
@@ -30,7 +25,6 @@ import MkNote from '@/components/MkNote.vue';
 import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { infoImageUrl } from '@/instance.js';
 
 const pagination = {
 	endpoint: 'i/favorites' as const,
diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue
index 8ea385a74f..9b4e3faaef 100644
--- a/packages/frontend/src/pages/follow-requests.vue
+++ b/packages/frontend/src/pages/follow-requests.vue
@@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true">
 	<div class="_spacer" style="--MI_SPACER-w: 800px;">
 		<MkPagination ref="paginationComponent" :pagination="pagination">
-			<template #empty>
-				<div class="_fullinfo">
-					<img :src="infoImageUrl" draggable="false"/>
-					<div>{{ i18n.ts.noFollowRequests }}</div>
-				</div>
-			</template>
+			<template #empty><MkResult type="empty" :text="i18n.ts.noFollowRequests"/></template>
 			<template #default="{items}">
 				<div class="mk-follow-requests _gaps">
 					<div v-for="req in items" :key="req.id" class="user _panel">
@@ -48,7 +43,6 @@ import { userPage, acct } from '@/filters/user.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { infoImageUrl } from '@/instance.js';
 import { $i } from '@/i.js';
 
 const paginationComponent = useTemplateRef('paginationComponent');
diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue
index cc114ae9b3..406c08bcf2 100644
--- a/packages/frontend/src/pages/invite.vue
+++ b/packages/frontend/src/pages/invite.vue
@@ -6,13 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <PageWithHeader>
 	<div v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" class="_spacer" style="--MI_SPACER-w: 1200px;">
-		<div :class="$style.root">
-			<img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/>
-			<div :class="$style.text">
-				<i class="ti ti-alert-triangle"></i>
-				{{ i18n.ts.nothing }}
-			</div>
-		</div>
+		<MkResult type="empty"/>
 	</div>
 	<div v-else class="_spacer" style="--MI_SPACER-w: 800px;">
 		<div class="_gaps_m" style="text-align: center;">
@@ -43,7 +37,7 @@ import MkButton from '@/components/MkButton.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import MkInviteCode from '@/components/MkInviteCode.vue';
 import { definePage } from '@/page.js';
-import { serverErrorImageUrl, instance } from '@/instance.js';
+import { instance } from '@/instance.js';
 import { $i } from '@/i.js';
 
 const pagingComponent = useTemplateRef('pagingComponent');
@@ -96,23 +90,3 @@ definePage(() => ({
 	icon: 'ti ti-user-plus',
 }));
 </script>
-
-<style lang="scss" module>
-.root {
-	padding: 32px;
-	text-align: center;
-	align-items: center;
-}
-
-.text {
-	margin: 0 0 8px 0;
-}
-
-.img {
-	vertical-align: bottom;
-	width: 128px;
-	height: 128px;
-	margin-bottom: 16px;
-	border-radius: 16px;
-}
-</style>
diff --git a/packages/frontend/src/pages/list.vue b/packages/frontend/src/pages/list.vue
index e9e3c79be5..4368aff8be 100644
--- a/packages/frontend/src/pages/list.vue
+++ b/packages/frontend/src/pages/list.vue
@@ -6,13 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <PageWithHeader :actions="headerActions" :tabs="headerTabs">
 	<div v-if="error != null" class="_spacer" style="--MI_SPACER-w: 1200px;">
-		<div :class="$style.root">
-			<img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/>
-			<p :class="$style.text">
-				<i class="ti ti-alert-triangle"></i>
-				{{ i18n.ts.nothing }}
-			</p>
-		</div>
+		<MkResult type="error"/>
 	</div>
 	<div v-else-if="list" class="_spacer" style="--MI_SPACER-w: 700px;">
 		<div v-if="list" class="members _margin">
@@ -42,7 +36,6 @@ import { i18n } from '@/i18n.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import MkButton from '@/components/MkButton.vue';
 import { definePage } from '@/page.js';
-import { serverErrorImageUrl } from '@/instance.js';
 
 const props = defineProps<{
 	listId: string;
diff --git a/packages/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue
index 6f623abb64..95a3108e3a 100644
--- a/packages/frontend/src/pages/my-antennas/index.vue
+++ b/packages/frontend/src/pages/my-antennas/index.vue
@@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <PageWithHeader :actions="headerActions" :tabs="headerTabs">
 	<div class="_spacer" style="--MI_SPACER-w: 700px;">
 		<div>
-			<div v-if="antennas.length === 0" class="empty">
-				<div class="_fullinfo">
-					<img :src="infoImageUrl" draggable="false"/>
-					<div>{{ i18n.ts.nothing }}</div>
-				</div>
-			</div>
+			<MkResult v-if="antennas.length === 0" type="empty"/>
 
 			<MkButton :link="true" to="/my/antennas/create" primary :class="$style.add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
 
@@ -32,7 +27,6 @@ import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { antennasCache } from '@/cache.js';
-import { infoImageUrl } from '@/instance.js';
 
 const antennas = computed(() => antennasCache.value.value ?? []);
 
diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue
index c974f3afc7..41afabff99 100644
--- a/packages/frontend/src/pages/my-lists/index.vue
+++ b/packages/frontend/src/pages/my-lists/index.vue
@@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <PageWithHeader :actions="headerActions" :tabs="headerTabs">
 	<div class="_spacer" style="--MI_SPACER-w: 700px;">
 		<div class="_gaps">
-			<div v-if="items.length === 0" class="empty">
-				<div class="_fullinfo">
-					<img :src="infoImageUrl" draggable="false"/>
-					<div>{{ i18n.ts.nothing }}</div>
-				</div>
-			</div>
+			<MkResult v-if="items.length === 0" type="empty"/>
 
 			<MkButton primary rounded style="margin: 0 auto;" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton>
 
@@ -35,7 +30,6 @@ import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { userListsCache } from '@/cache.js';
-import { infoImageUrl } from '@/instance.js';
 import { ensureSignin } from '@/i.js';
 
 const $i = ensureSignin();
diff --git a/packages/frontend/src/pages/not-found.vue b/packages/frontend/src/pages/not-found.vue
index 684a3bb5bd..305518f64a 100644
--- a/packages/frontend/src/pages/not-found.vue
+++ b/packages/frontend/src/pages/not-found.vue
@@ -5,10 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div>
-	<div class="_fullinfo">
-		<img :src="notFoundImageUrl" draggable="false"/>
-		<div>{{ i18n.ts.notFoundDescription }}</div>
-	</div>
+	<MkResult type="notFound" :text="i18n.ts.notFoundDescription"/>
 </div>
 </template>
 
@@ -17,7 +14,6 @@ import { computed } from 'vue';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { pleaseLogin } from '@/utility/please-login.js';
-import { notFoundImageUrl } from '@/instance.js';
 
 const props = defineProps<{
 	showLoginPopup?: boolean;
diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue
index 82e5999406..9d01edb255 100644
--- a/packages/frontend/src/pages/role.vue
+++ b/packages/frontend/src/pages/role.vue
@@ -6,30 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <PageWithHeader v-model:tab="tab" :tabs="headerTabs">
 	<div v-if="error != null" class="_spacer" style="--MI_SPACER-w: 1200px;">
-		<div :class="$style.root">
-			<img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/>
-			<p :class="$style.text">
-				<i class="ti ti-alert-triangle"></i>
-				{{ error }}
-			</p>
-		</div>
+		<MkResult type="error" :text="error"/>
 	</div>
 	<div v-else-if="tab === 'users'" class="_spacer" style="--MI_SPACER-w: 1200px;">
 		<div class="_gaps_s">
 			<div v-if="role">{{ role.description }}</div>
 			<MkUserList v-if="visible" :pagination="users" :extractor="(item) => item.user"/>
-			<div v-else-if="!visible" class="_fullinfo">
-				<img :src="infoImageUrl" draggable="false"/>
-				<div>{{ i18n.ts.nothing }}</div>
-			</div>
+			<MkResult v-else-if="!visible" type="empty" :text="i18n.ts.nothing"/>
 		</div>
 	</div>
 	<div v-else-if="tab === 'timeline'" class="_spacer" style="--MI_SPACER-w: 700px;">
 		<MkTimeline v-if="visible" ref="timeline" src="role" :role="props.roleId"/>
-		<div v-else-if="!visible" class="_fullinfo">
-			<img :src="infoImageUrl" draggable="false"/>
-			<div>{{ i18n.ts.nothing }}</div>
-		</div>
+		<MkResult v-else-if="!visible" type="empty" :text="i18n.ts.nothing"/>
 	</div>
 </PageWithHeader>
 </template>
@@ -37,13 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, watch, ref } from 'vue';
 import * as Misskey from 'misskey-js';
-import { instanceName } from '@@/js/config.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import MkUserList from '@/components/MkUserList.vue';
 import { definePage } from '@/page.js';
 import { i18n } from '@/i18n.js';
 import MkTimeline from '@/components/MkTimeline.vue';
-import { serverErrorImageUrl, infoImageUrl } from '@/instance.js';
 
 const props = withDefaults(defineProps<{
 	roleId: string;
@@ -97,24 +83,3 @@ definePage(() => ({
 	icon: 'ti ti-badge',
 }));
 </script>
-
-<style lang="scss" module>
-.root {
-	padding: 32px;
-	text-align: center;
-  align-items: center;
-}
-
-.text {
-	margin: 0 0 8px 0;
-}
-
-.img {
-	vertical-align: bottom;
-  width: 128px;
-	height: 128px;
-	margin-bottom: 16px;
-	border-radius: 16px;
-}
-</style>
-
diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue
index c72179b9a1..33c17e5d7f 100644
--- a/packages/frontend/src/pages/settings/apps.vue
+++ b/packages/frontend/src/pages/settings/apps.vue
@@ -6,12 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <div class="_gaps_m">
 	<FormPagination ref="list" :pagination="pagination">
-		<template #empty>
-			<div class="_fullinfo">
-				<img :src="infoImageUrl" draggable="false"/>
-				<div>{{ i18n.ts.nothing }}</div>
-			</div>
-		</template>
+		<template #empty><MkResult type="empty"/></template>
 		<template #default="{items}">
 			<div class="_gaps">
 				<MkFolder v-for="token in items" :key="token.id" :defaultOpen="true">
@@ -63,7 +58,6 @@ import { definePage } from '@/page.js';
 import MkKeyValue from '@/components/MkKeyValue.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkFolder from '@/components/MkFolder.vue';
-import { infoImageUrl } from '@/instance.js';
 
 const list = ref<InstanceType<typeof FormPagination>>();
 
diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue
index fc9cd8f892..7c2376249e 100644
--- a/packages/frontend/src/pages/settings/mute-block.vue
+++ b/packages/frontend/src/pages/settings/mute-block.vue
@@ -69,12 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<template #label><SearchLabel>{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</SearchLabel></template>
 
 					<MkPagination :pagination="renoteMutingPagination">
-						<template #empty>
-							<div class="_fullinfo">
-								<img :src="infoImageUrl" draggable="false"/>
-								<div>{{ i18n.ts.noUsers }}</div>
-							</div>
-						</template>
+						<template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template>
 
 						<template #default="{ items }">
 							<div class="_gaps_s">
@@ -105,12 +100,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<template #label>{{ i18n.ts.mutedUsers }}</template>
 
 					<MkPagination :pagination="mutingPagination">
-						<template #empty>
-							<div class="_fullinfo">
-								<img :src="infoImageUrl" draggable="false"/>
-								<div>{{ i18n.ts.noUsers }}</div>
-							</div>
-						</template>
+						<template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template>
 
 						<template #default="{ items }">
 							<div class="_gaps_s">
@@ -143,12 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<template #label>{{ i18n.ts.blockedUsers }}</template>
 
 					<MkPagination :pagination="blockingPagination">
-						<template #empty>
-							<div class="_fullinfo">
-								<img :src="infoImageUrl" draggable="false"/>
-								<div>{{ i18n.ts.noUsers }}</div>
-							</div>
-						</template>
+						<template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template>
 
 						<template #default="{ items }">
 							<div class="_gaps_s">
@@ -186,7 +171,7 @@ import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import * as os from '@/os.js';
-import { instance, infoImageUrl } from '@/instance.js';
+import { instance } from '@/instance.js';
 import { ensureSignin } from '@/i.js';
 import MkInfo from '@/components/MkInfo.vue';
 import MkFolder from '@/components/MkFolder.vue';
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index b7ca0cfd01..341f5cb621 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -486,18 +486,6 @@ rt {
 	}
 }
 
-._fullinfo {
-	padding: 64px 32px;
-	text-align: center;
-
-	> img {
-		vertical-align: bottom;
-		height: 128px;
-		margin-bottom: 16px;
-		border-radius: 16px;
-	}
-}
-
 ._link {
 	color: var(--MI_THEME-link);
 }
diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
index 6fe743aed2..4790f143cb 100644
--- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
+++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
@@ -15,8 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<MkAvatar v-for="user in users" :key="user.id" :user="user.followee" link preview></MkAvatar>
 		</div>
 		<div v-else :class="$style.bdayFFallback">
-			<img :src="infoImageUrl" draggable="false" :class="$style.bdayFFallbackImage"/>
-			<div>{{ i18n.ts.nothing }}</div>
+			<MkResult type="empty"/>
 		</div>
 	</div>
 </MkContainer>
@@ -32,7 +31,6 @@ import type { GetFormResultType } from '@/utility/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
-import { infoImageUrl } from '@/instance.js';
 import { $i } from '@/i.js';
 
 const name = i18n.ts._widgets.birthdayFollowings;
@@ -134,12 +132,4 @@ defineExpose<WidgetComponentExpose>({
 	justify-content: center;
 	align-items: center;
 }
-
-.bdayFFallbackImage {
-	height: 96px;
-	width: auto;
-	max-width: 90%;
-	margin-bottom: 8px;
-	border-radius: var(--MI-radius);
-}
 </style>
diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue
index 132eb0a629..2594262df1 100644
--- a/packages/frontend/src/widgets/WidgetRss.vue
+++ b/packages/frontend/src/widgets/WidgetRss.vue
@@ -11,10 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 	<div class="ekmkgxbj">
 		<MkLoading v-if="fetching"/>
-		<div v-else-if="(!items || items.length === 0) && widgetProps.showHeader" class="_fullinfo">
-			<img :src="infoImageUrl" draggable="false"/>
-			<div>{{ i18n.ts.nothing }}</div>
-		</div>
+		<MkResult v-else-if="(!items || items.length === 0) && widgetProps.showHeader" type="empty"/>
 		<div v-else :class="$style.feed">
 			<a v-for="item in items" :key="item.link" :class="$style.item" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a>
 		</div>
@@ -32,7 +29,6 @@ import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps
 import type { GetFormResultType } from '@/utility/form.js';
 import MkContainer from '@/components/MkContainer.vue';
 import { i18n } from '@/i18n.js';
-import { infoImageUrl } from '@/instance.js';
 
 const name = 'rss';