feat: このユーザーのノートを検索, クエリに基づく検索の初期値 & ノート検索のUI改善 (#14128)
* refactor(frontend): noteSearchAvailableをaccountsに移動 * feat: searchページでのクエリの受取りとtypeによる表示タブの変更 * user検索でsearchの親から受け取った値を基に入力値を初期化 * feat(frontend): ノート検索で親(search)からの情報を基にユーザー情報を取得 * feat(frontend): ユーザーのノートを検索するページに遷移するボタン * feat(frontend): ノート検索にホスト名指定のオプション追加 also 🎨 * 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: ノート検索で自分を選択可能に & 🎨 * feat(background): api/metaで検索可能なノートのスコープを参照できるように * globalのノートが検索不可能な場合、検索オプションを表示しないように * Update CHANGELOG.md * config.meilisearch.scopeがstring[]を取ることがあるので修正 * meilisearchを利用かつscopeがlocalの場合、リモートユーザーのメニューで「このユーザーのノートを検索」を出さないように * hostが空文字の時の挙動を修正 * ローカルのみしかノートがインデックスされていない場合、リモートユーザーも選択できなくした
This commit is contained in:
		| @@ -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> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 taichan
					taichan