From bff813042e670dcb59a6b0dcc334ca44f880752b Mon Sep 17 00:00:00 2001
From: taichan <40626578+tai-cha@users.noreply.github.com>
Date: Tue, 30 Jul 2024 15:51:08 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=E3=81=93=E3=81=AE=E3=83=A6=E3=83=BC?=
 =?UTF-8?q?=E3=82=B6=E3=83=BC=E3=81=AE=E3=83=8E=E3=83=BC=E3=83=88=E3=82=92?=
 =?UTF-8?q?=E6=A4=9C=E7=B4=A2,=20=E3=82=AF=E3=82=A8=E3=83=AA=E3=81=AB?=
 =?UTF-8?q?=E5=9F=BA=E3=81=A5=E3=81=8F=E6=A4=9C=E7=B4=A2=E3=81=AE=E5=88=9D?=
 =?UTF-8?q?=E6=9C=9F=E5=80=A4=20&=20=E3=83=8E=E3=83=BC=E3=83=88=E6=A4=9C?=
 =?UTF-8?q?=E7=B4=A2=E3=81=AEUI=E6=94=B9=E5=96=84=20(#14128)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* refactor(frontend): noteSearchAvailableをaccountsに移動

* feat: searchページでのクエリの受取りとtypeによる表示タブの変更

* user検索でsearchの親から受け取った値を基に入力値を初期化

* feat(frontend): ノート検索で親(search)からの情報を基にユーザー情報を取得

* feat(frontend): ユーザーのノートを検索するページに遷移するボタン

* feat(frontend): ノート検索にホスト名指定のオプション追加
also :art:

* style: ただ照会部分を囲っただけ(可読性確保のために)

* refactor: remove unneed import
defineProps and withDefaults are compiler micro when using `<script setup>`
FYI: https://vuejs.org/api/sfc-script-setup.html#defineprops-defineemits:~:text=defineProps%20and%20defineEmits%20are%20compiler%20macros%20only%20usable%20inside%20%3Cscript%20setup%3E.%20They%20do%20not%20need%20to%20be%20imported%2C%20and%20are%20compiled%20away%20when%20%3Cscript%20setup%3E%20is%20processed.

* Update CHANGELOG

* Fix: ノート検索の初期値が常にホスト指定になってしまう

* notesSearchAvailableをaccountに持たせるのをやめる

* SDPX-Licence-Identifier

* Fix: Vitest fails due to instance.policies being undefined

* Add Storybook for search

* Fix(storybook): ノート検索が利用できないと出てしまう問題

* storybookでユーザー選択ができないのを修正

* feat: ノート検索で自分を選択可能に
& :art:

* feat(background): api/metaで検索可能なノートのスコープを参照できるように

* globalのノートが検索不可能な場合、検索オプションを表示しないように

* Update CHANGELOG.md

* config.meilisearch.scopeがstring[]を取ることがあるので修正

* meilisearchを利用かつscopeがlocalの場合、リモートユーザーのメニューで「このユーザーのノートを検索」を出さないように

* hostが空文字の時の挙動を修正

* ローカルのみしかノートがインデックスされていない場合、リモートユーザーも選択できなくした
---
 CHANGELOG.md                                  |   4 +
 locales/index.d.ts                            |  12 ++
 locales/ja-JP.yml                             |   3 +
 .../src/core/entities/MetaEntityService.ts    |   1 +
 .../backend/src/models/json-schema/meta.ts    |   6 +
 packages/frontend/.storybook/fakes.ts         |   2 +-
 packages/frontend/.storybook/generate.tsx     |   1 +
 packages/frontend/src/components/MkRadios.vue |   4 +
 packages/frontend/src/pages/search.note.vue   | 152 +++++++++++++++---
 .../frontend/src/pages/search.stories.impl.ts |  88 ++++++++++
 packages/frontend/src/pages/search.user.vue   |  20 ++-
 packages/frontend/src/pages/search.vue        |  34 ++--
 packages/frontend/src/router/definition.ts    |   3 +
 .../frontend/src/scripts/check-permissions.ts |  19 +++
 .../frontend/src/scripts/get-user-menu.ts     |  10 +-
 packages/misskey-js/src/autogen/types.ts      |   5 +
 16 files changed, 327 insertions(+), 37 deletions(-)
 create mode 100644 packages/frontend/src/pages/search.stories.impl.ts
 create mode 100644 packages/frontend/src/scripts/check-permissions.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33256a8674..f58bec42e6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,9 @@
 - Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正
 
 ### Client
+- Feat: ユーザーページから「このユーザーのノートを検索」できるように (#14128)
+- Feat: 検索ページはクエリを受け付けるようになりました (#14128)
+- Enhance: 検索ページのUI改善 (#14128)
 - Enhance: 内蔵APIドキュメントのデザイン・パフォーマンスを改善
 - Enhance: 非ログイン時に他サーバーに遷移するアクションを追加
 - Enhance: 非ログイン時のハイライトTLのデザインを改善
@@ -61,6 +64,7 @@
 - Enhance: エンドポイント`i/webhook/update`の必須項目を`webhookId`のみに
 - Enhance: エンドポイント`admin/ad/update`の必須項目を`id`のみに
 - Enhance: `default.yml`内の`url`, `db.db`, `db.user`, `db.pass`を環境変数から読み込めるように
+- Enhance: エンドポイント`api/meta`にプロパティ`noteSearchableScope`が増え、`string`値`local`または`global`を返却します
 - Fix: チャート生成時にinstance.suspensionStateに置き換えられたinstance.isSuspendedが参照されてしまう問題を修正
 - Fix: ユーザーのフィードページのMFMをHTMLに展開するように (#14006)
 - Fix: アンテナ・クリップ・リスト・ウェブフックがロールポリシーの上限より一つ多く作れてしまうのを修正 (#14036)
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 30c21c92e7..14dc862745 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -256,6 +256,10 @@ export interface Locale extends ILocale {
      * ユーザーを検索
      */
     "searchUser": string;
+    /**
+     * ユーザーのノートを検索
+     */
+    "searchThisUsersNotes": string;
     /**
      * 返信
      */
@@ -796,6 +800,10 @@ export interface Locale extends ILocale {
      * ホスト
      */
     "host": string;
+    /**
+     * 自分を選択
+     */
+    "selectSelf": string;
     /**
      * ユーザーを選択
      */
@@ -4492,6 +4500,10 @@ export interface Locale extends ILocale {
      * ユーザー指定
      */
     "specifyUser": string;
+    /**
+     * ホスト指定
+     */
+    "specifyHost": string;
     /**
      * プレビューできません
      */
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index ea2ffb9c0d..cd26b71d57 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -60,6 +60,7 @@ copyFileId: "ファイルIDをコピー"
 copyFolderId: "フォルダーIDをコピー"
 copyProfileUrl: "プロフィールURLをコピー"
 searchUser: "ユーザーを検索"
+searchThisUsersNotes: "ユーザーのノートを検索"
 reply: "返信"
 loadMore: "もっと見る"
 showMore: "もっと見る"
@@ -195,6 +196,7 @@ followConfirm: "{name}をフォローしますか?"
 proxyAccount: "プロキシアカウント"
 proxyAccountDescription: "プロキシアカウントは、特定の条件下でユーザーのリモートフォローを代行するアカウントです。例えば、ユーザーがリモートユーザーをリストに入れたとき、リストに入れられたユーザーを誰もフォローしていないとアクティビティがサーバーに配達されないため、代わりにプロキシアカウントがフォローするようにします。"
 host: "ホスト"
+selectSelf: "自分を選択"
 selectUser: "ユーザーを選択"
 recipient: "宛先"
 annotation: "注釈"
@@ -1119,6 +1121,7 @@ preventAiLearning: "生成AIによる学習を拒否"
 preventAiLearningDescription: "外部の文章生成AIや画像生成AIに対して、投稿したノートや画像などのコンテンツを学習の対象にしないように要求します。これはnoaiフラグをHTMLレスポンスに含めることによって実現されますが、この要求に従うかはそのAI次第であるため、学習を完全に防止するものではありません。"
 options: "オプション"
 specifyUser: "ユーザー指定"
+specifyHost: "ホスト指定"
 failedToPreviewUrl: "プレビューできません"
 update: "更新"
 rolesThatCanBeUsedThisEmojiAsReaction: "リアクションとして使えるロール"
diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts
index 09641ce485..bcfd8ae41c 100644
--- a/packages/backend/src/core/entities/MetaEntityService.ts
+++ b/packages/backend/src/core/entities/MetaEntityService.ts
@@ -128,6 +128,7 @@ export class MetaEntityService {
 
 			mediaProxy: this.config.mediaProxy,
 			enableUrlPreview: instance.urlPreviewEnabled,
+			noteSearchableScope: this.config.meilisearch == null || this.config.meilisearch.scope === 'local' ? 'local' : 'global',
 		};
 
 		return packed;
diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts
index e7bc6356e5..3bcf9cac92 100644
--- a/packages/backend/src/models/json-schema/meta.ts
+++ b/packages/backend/src/models/json-schema/meta.ts
@@ -247,6 +247,12 @@ export const packedMetaLiteSchema = {
 			optional: false, nullable: false,
 			ref: 'RolePolicies',
 		},
+		noteSearchableScope: {
+			type: 'string',
+			enum: ['local', 'global'],
+			optional: false, nullable: false,
+			default: 'local',
+		},
 	},
 } as const;
 
diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts
index 9d789a34ff..01f1046609 100644
--- a/packages/frontend/.storybook/fakes.ts
+++ b/packages/frontend/.storybook/fakes.ts
@@ -154,7 +154,7 @@ export function federationInstance(): entities.FederationInstance {
 	};
 }
 
-export function userDetailed(id = 'someuserid', username = 'miskist', host = 'misskey-hub.net', name = 'Misskey User'): entities.UserDetailed {
+export function userDetailed(id = 'someuserid', username = 'miskist', host:entities.UserDetailed['host'] = 'misskey-hub.net', name:entities.UserDetailed['name'] = 'Misskey User'): entities.UserDetailed {
 	return {
 		id,
 		username,
diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx
index 7b6c86447e..b94dfc53e3 100644
--- a/packages/frontend/.storybook/generate.tsx
+++ b/packages/frontend/.storybook/generate.tsx
@@ -405,6 +405,7 @@ function toStories(component: string): Promise<string> {
 		glob('src/components/MkUserSetupDialog.*.vue'),
 		glob('src/components/MkInstanceCardMini.vue'),
 		glob('src/components/MkInviteCode.vue'),
+		glob('src/pages/search.vue'),
 		glob('src/pages/user/home.vue'),
 	]);
 	const components = globs.flat();
diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue
index 549438f61b..705c93f770 100644
--- a/packages/frontend/src/components/MkRadios.vue
+++ b/packages/frontend/src/components/MkRadios.vue
@@ -29,6 +29,9 @@ export default defineComponent({
 		// なぜかFragmentになることがあるため
 		if (options.length === 1 && options[0].props == null) options = options[0].children as VNode[];
 
+		// vnodeのうちv-if=falseなものを除外する(trueになるものはoptionなど他typeになる)
+		options = options.filter(vnode => !(typeof vnode.type === 'symbol' && vnode.type.description === 'v-cmt' && vnode.children === 'v-if'));
+
 		return () => h('div', {
 			class: 'novjtcto',
 		}, [
@@ -40,6 +43,7 @@ export default defineComponent({
 			}, options.map(option => h(MkRadio, {
 				key: option.key as string,
 				value: option.props?.value,
+				disabled: option.props?.disabled,
 				modelValue: value.value,
 				'onUpdate:modelValue': _v => value.value = _v,
 			}, () => option.children)),
diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue
index d68bbaeeca..05dc77b94b 100644
--- a/packages/frontend/src/pages/search.note.vue
+++ b/packages/frontend/src/pages/search.note.vue
@@ -9,26 +9,35 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search">
 			<template #prefix><i class="ti ti-search"></i></template>
 		</MkInput>
-		<MkFolder>
-			<template #label>{{ i18n.ts.options }}</template>
+		<MkFoldableSection :expanded="true">
+			<template #header>{{ i18n.ts.options }}</template>
 
 			<div class="_gaps_m">
-				<MkSwitch v-model="isLocalOnly">{{ i18n.ts.localOnly }}</MkSwitch>
+				<MkRadios v-model="hostSelect">
+					<template #label>{{ i18n.ts.host }}</template>
+					<option value="all" default>{{ i18n.ts.all }}</option>
+					<option value="local">{{ i18n.ts.local }}</option>
+					<option v-if="noteSearchableScope === 'global'" value="specified">{{ i18n.ts.specifyHost }}</option>
+				</MkRadios>
+				<MkInput v-if="noteSearchableScope === 'global'" v-model="hostInput" :disabled="hostSelect !== 'specified'" :large="true" type="search">
+					<template #prefix><i class="ti ti-server"></i></template>
+				</MkInput>
 
 				<MkFolder :defaultOpen="true">
 					<template #label>{{ i18n.ts.specifyUser }}</template>
-					<template v-if="user" #suffix>@{{ user.username }}</template>
+					<template v-if="user" #suffix>@{{ user.username }}{{ user.host ? `@${user.host}` : "" }}</template>
 
-					<div style="text-align: center;" class="_gaps">
-						<div v-if="user">@{{ user.username }}</div>
-						<div>
-							<MkButton v-if="user == null" primary rounded inline @click="selectUser">{{ i18n.ts.selectUser }}</MkButton>
-							<MkButton v-else danger rounded inline @click="user = null">{{ i18n.ts.remove }}</MkButton>
+					<div class="_gaps">
+						<div :class="$style.userItem">
+							<MkUserCardMini v-if="user" :class="$style.userCard" :user="user" :withChart="false"/>
+							<MkButton v-if="user == null && $i != null" transparent :class="$style.addMeButton" @click="selectSelf"><div :class="$style.addUserButtonInner"><span><i class="ti ti-plus"></i><i class="ti ti-user"></i></span><span>{{ i18n.ts.selectSelf }}</span></div></MkButton>
+							<MkButton v-if="user == null" transparent :class="$style.addUserButton" @click="selectUser"><div :class="$style.addUserButtonInner"><i class="ti ti-plus"></i><span>{{ i18n.ts.selectUser }}</span></div></MkButton>
+							<button class="_button" :class="$style.remove" :disabled="user == null" @click="removeUser"><i class="ti ti-x"></i></button>
 						</div>
 					</div>
 				</MkFolder>
 			</div>
-		</MkFolder>
+		</MkFoldableSection>
 		<div>
 			<MkButton large primary gradate rounded style="margin: 0 auto;" @click="search">{{ i18n.ts.search }}</MkButton>
 		</div>
@@ -42,31 +51,90 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref } from 'vue';
+import { computed, ref, toRef, watch } from 'vue';
+import type { UserDetailed } from 'misskey-js/entities.js';
+import type { Paging } from '@/components/MkPagination.vue';
 import MkNotes from '@/components/MkNotes.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkButton from '@/components/MkButton.vue';
-import MkSwitch from '@/components/MkSwitch.vue';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import { useRouter } from '@/router/supplier.js';
+import MkUserCardMini from '@/components/MkUserCardMini.vue';
+import MkRadios from '@/components/MkRadios.vue';
+import { $i } from '@/account.js';
+import { instance } from '@/instance.js';
+
+const props = withDefaults(defineProps<{
+	query?: string;
+	userId?: string;
+	username?: string;
+	host?: string | null;
+}>(), {
+	query: '',
+	userId: undefined,
+	username: undefined,
+	host: '',
+});
 
 const router = useRouter();
-
 const key = ref(0);
-const searchQuery = ref('');
-const searchOrigin = ref('combined');
-const notePagination = ref();
-const user = ref<any>(null);
-const isLocalOnly = ref(false);
+const searchQuery = ref(toRef(props, 'query').value);
+const notePagination = ref<Paging>();
+const user = ref<UserDetailed | null>(null);
+const hostInput = ref(toRef(props, 'host').value);
 
-function selectUser() {
-	os.selectUser({ includeSelf: true }).then(_user => {
+const noteSearchableScope = instance.noteSearchableScope ?? 'local';
+
+const hostSelect = ref<'all' | 'local' | 'specified'>('all');
+
+const setHostSelectWithInput = (after:string|undefined|null, before:string|undefined|null) => {
+	if (before === after) return;
+	if (after === '') hostSelect.value = 'all';
+	else hostSelect.value = 'specified';
+};
+
+setHostSelectWithInput(hostInput.value, undefined);
+
+watch(hostInput, setHostSelectWithInput);
+
+const searchHost = computed(() => {
+	if (hostSelect.value === 'local') return '.';
+	if (hostSelect.value === 'specified') return hostInput.value;
+	return null;
+});
+
+if (props.userId != null) {
+	misskeyApi('users/show', { userId: props.userId }).then(_user => {
 		user.value = _user;
 	});
+} else if (props.username != null) {
+	misskeyApi('users/show', {
+		username: props.username,
+		...(props.host != null && props.host !== '') ? { host: props.host } : {},
+	}).then(_user => {
+		user.value = _user;
+	});
+}
+
+function selectUser() {
+	os.selectUser({ includeSelf: true, localOnly: instance.noteSearchableScope === 'local' }).then(_user => {
+		user.value = _user;
+		hostInput.value = _user.host ?? '';
+	});
+}
+
+function selectSelf() {
+	user.value = $i as UserDetailed | null;
+	hostInput.value = null;
+}
+
+function removeUser() {
+	user.value = null;
+	hostInput.value = '';
 }
 
 async function search() {
@@ -74,6 +142,7 @@ async function search() {
 
 	if (query == null || query === '') return;
 
+	//#region AP lookup
 	if (query.startsWith('https://')) {
 		const promise = misskeyApi('ap/show', {
 			uri: query,
@@ -91,6 +160,7 @@ async function search() {
 
 		return;
 	}
+	//#endregion
 
 	notePagination.value = {
 		endpoint: 'notes/search',
@@ -98,11 +168,49 @@ async function search() {
 		params: {
 			query: searchQuery.value,
 			userId: user.value ? user.value.id : null,
+			...(searchHost.value ? { host: searchHost.value } : {}),
 		},
 	};
 
-	if (isLocalOnly.value) notePagination.value.params.host = '.';
-
 	key.value++;
 }
 </script>
+<style lang="scss" module>
+.userItem {
+	display: flex;
+	justify-content: center;
+}
+.addMeButton {
+  border: 2px dashed var(--fgTransparent);
+	padding: 12px;
+	margin-right: 16px;
+}
+.addUserButton {
+  border: 2px dashed var(--fgTransparent);
+	padding: 12px;
+	flex-grow: 1;
+}
+.addUserButtonInner {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: space-between;
+	min-height: 38px;
+}
+.userCard {
+	flex-grow: 1;
+}
+.remove {
+	width: 32px;
+	height: 32px;
+	align-self: center;
+
+	& > i:before {
+		color: #ff2a2a;
+	}
+
+	&:disabled {
+		opacity: 0;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/search.stories.impl.ts b/packages/frontend/src/pages/search.stories.impl.ts
new file mode 100644
index 0000000000..0110a7ab8e
--- /dev/null
+++ b/packages/frontend/src/pages/search.stories.impl.ts
@@ -0,0 +1,88 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { StoryObj } from '@storybook/vue3';
+import { HttpResponse, http } from 'msw';
+import search_ from './search.vue';
+import { userDetailed } from '@/../.storybook/fakes.js';
+import { commonHandlers } from '@/../.storybook/mocks.js';
+
+const localUser = userDetailed('someuserid', 'miskist', null, 'Local Misskey User');
+
+export const Default = {
+	render(args) {
+		return {
+			components: {
+				search_,
+			},
+			setup() {
+				return {
+					args,
+				};
+			},
+			computed: {
+				props() {
+					return {
+						...this.args,
+					};
+				},
+			},
+			template: '<search_ v-bind="props" />',
+		};
+	},
+	args: {
+		ignoreNotesSearchAvailable: true,
+	},
+	parameters: {
+		layout: 'fullscreen',
+		msw: {
+			handlers: [
+				...commonHandlers,
+				http.post('/api/users/show', () => {
+					return HttpResponse.json(userDetailed());
+				}),
+				http.post('/api/users/search', () => {
+					return HttpResponse.json([userDetailed(), localUser]);
+				}),
+			],
+		},
+	},
+} satisfies StoryObj<typeof search_>;
+
+export const NoteSearchDisabled = {
+	...Default,
+	args: {},
+} satisfies StoryObj<typeof search_>;
+
+export const WithUsernameLocal = {
+	...Default,
+
+	args: {
+		...Default.args,
+		username: localUser.username,
+		host: localUser.host,
+	},
+	parameters: {
+		layout: 'fullscreen',
+		msw: {
+			handlers: [
+				...commonHandlers,
+				http.post('/api/users/show', () => {
+					return HttpResponse.json(localUser);
+				}),
+				http.post('/api/users/search', () => {
+					return HttpResponse.json([userDetailed(), localUser]);
+				}),
+			],
+		},
+	},
+} satisfies StoryObj<typeof search_>;
+
+export const WithUserType = {
+	...Default,
+	args: {
+		type: 'user',
+	},
+} satisfies StoryObj<typeof search_>;
diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue
index b9c2704bc7..85d869d9cb 100644
--- a/packages/frontend/src/pages/search.user.vue
+++ b/packages/frontend/src/pages/search.user.vue
@@ -25,7 +25,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref } from 'vue';
+import { ref, toRef } from 'vue';
+import type { Endpoints } from 'misskey-js';
+import type { Paging } from '@/components/MkPagination.vue';
 import MkUserList from '@/components/MkUserList.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkRadios from '@/components/MkRadios.vue';
@@ -36,18 +38,27 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { useRouter } from '@/router/supplier.js';
 
+const props = withDefaults(defineProps<{
+	query?: string,
+	origin?: Endpoints['users/search']['req']['origin'],
+}>(), {
+	query: '',
+	origin: 'combined',
+});
+
 const router = useRouter();
 
 const key = ref('');
-const searchQuery = ref('');
-const searchOrigin = ref('combined');
-const userPagination = ref();
+const searchQuery = ref(toRef(props, 'query').value);
+const searchOrigin = ref(toRef(props, 'origin').value);
+const userPagination = ref<Paging>();
 
 async function search() {
 	const query = searchQuery.value.toString().trim();
 
 	if (query == null || query === '') return;
 
+	//#region AP lookup
 	if (query.startsWith('https://')) {
 		const promise = misskeyApi('ap/show', {
 			uri: query,
@@ -65,6 +76,7 @@ async function search() {
 
 		return;
 	}
+	//#endregion
 
 	userPagination.value = {
 		endpoint: 'users/search',
diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue
index a3dcda77be..38d7548fa8 100644
--- a/packages/frontend/src/pages/search.vue
+++ b/packages/frontend/src/pages/search.vue
@@ -9,8 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 	<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
 		<MkSpacer v-if="tab === 'note'" key="note" :contentMax="800">
-			<div v-if="notesSearchAvailable">
-				<XNote/>
+			<div v-if="notesSearchAvailable || ignoreNotesSearchAvailable">
+				<XNote v-bind="props"/>
 			</div>
 			<div v-else>
 				<MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo>
@@ -18,27 +18,43 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</MkSpacer>
 
 		<MkSpacer v-else-if="tab === 'user'" key="user" :contentMax="800">
-			<XUser/>
+			<XUser v-bind="props"/>
 		</MkSpacer>
 	</MkHorizontalSwipe>
 </MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
-import { computed, defineAsyncComponent, ref } from 'vue';
+import { computed, defineAsyncComponent, ref, toRef } from 'vue';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { $i } from '@/account.js';
-import { instance } from '@/instance.js';
+import { notesSearchAvailable } from '@/scripts/check-permissions.js';
 import MkInfo from '@/components/MkInfo.vue';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 
+const props = withDefaults(defineProps<{
+	query?: string,
+	userId?: string,
+	username?: string,
+	host?: string | null,
+	type?: 'note' | 'user',
+	origin?: 'combined' | 'local' | 'remote',
+	// For storybook only
+	ignoreNotesSearchAvailable?: boolean,
+}>(), {
+	query: '',
+	userId: undefined,
+	username: undefined,
+	host: undefined,
+	type: 'note',
+	origin: 'combined',
+	ignoreNotesSearchAvailable: false,
+});
+
 const XNote = defineAsyncComponent(() => import('./search.note.vue'));
 const XUser = defineAsyncComponent(() => import('./search.user.vue'));
 
-const tab = ref('note');
-
-const notesSearchAvailable = (($i == null && instance.policies.canSearchNotes) || ($i != null && $i.policies.canSearchNotes));
+const tab = ref(toRef(props, 'type').value);
 
 const headerActions = computed(() => []);
 
diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts
index f7a219c57e..995a2055b8 100644
--- a/packages/frontend/src/router/definition.ts
+++ b/packages/frontend/src/router/definition.ts
@@ -232,6 +232,9 @@ const routes: RouteDef[] = [{
 	component: page(() => import('@/pages/search.vue')),
 	query: {
 		q: 'query',
+		userId: 'userId',
+		username: 'username',
+		host: 'host',
 		channel: 'channel',
 		type: 'type',
 		origin: 'origin',
diff --git a/packages/frontend/src/scripts/check-permissions.ts b/packages/frontend/src/scripts/check-permissions.ts
new file mode 100644
index 0000000000..ed86529d5b
--- /dev/null
+++ b/packages/frontend/src/scripts/check-permissions.ts
@@ -0,0 +1,19 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { instance } from '@/instance.js';
+import { $i } from '@/account.js';
+
+export const notesSearchAvailable = (
+	// FIXME: instance.policies would be null in Vitest
+	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+	($i == null && instance.policies != null && instance.policies.canSearchNotes) ||
+	($i != null && $i.policies.canSearchNotes) ||
+	false
+) as boolean;
+
+export const canSearchNonLocalNotes = (
+	instance.noteSearchableScope === 'global'
+);
diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts
index dacafb859f..e9e7ae771d 100644
--- a/packages/frontend/src/scripts/get-user-menu.ts
+++ b/packages/frontend/src/scripts/get-user-menu.ts
@@ -13,6 +13,7 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { defaultStore, userActions } from '@/store.js';
 import { $i, iAmModerator } from '@/account.js';
+import { notesSearchAvailable, canSearchNonLocalNotes } from '@/scripts/check-permissions.js';
 import { IRouter } from '@/nirax.js';
 import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
 import { mainRouter } from '@/router/main.js';
@@ -160,7 +161,14 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
 		action: () => {
 			copyToClipboard(`@${user.username}@${user.host ?? host}`);
 		},
-	}, ...(iAmModerator ? [{
+	}, ...( notesSearchAvailable && (user.host == null || canSearchNonLocalNotes) ? [{
+		icon: 'ti ti-search',
+		text: i18n.ts.searchThisUsersNotes,
+		action: () => {
+			router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`);
+		},
+	}] : [])
+	, ...(iAmModerator ? [{
 		icon: 'ti ti-user-exclamation',
 		text: i18n.ts.moderation,
 		action: () => {
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 4cfe92147a..cb7d82d2d8 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -4939,6 +4939,11 @@ export type components = {
       serverRules: string[];
       themeColor: string | null;
       policies: components['schemas']['RolePolicies'];
+      /**
+       * @default local
+       * @enum {string}
+       */
+      noteSearchableScope: 'local' | 'global';
     };
     MetaDetailedOnly: {
       features?: {