From f9f2d3c2f48b2469ba02ed817e41d4190602ff08 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 17 Nov 2025 17:38:46 +0100 Subject: [PATCH 1/3] fix: split search requests for Talk messages, extract types Signed-off-by: Maksim Sukharev --- .../SearchMessages/SearchMessagesTab.vue | 15 ++++----------- src/services/coreService.ts | 17 +++++++++++++++-- src/types/core.ts | 9 +++++++++ src/types/index.ts | 1 + 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/components/RightSidebar/SearchMessages/SearchMessagesTab.vue b/src/components/RightSidebar/SearchMessages/SearchMessagesTab.vue index 210ec520e67..9ad5d8f11d4 100644 --- a/src/components/RightSidebar/SearchMessages/SearchMessagesTab.vue +++ b/src/components/RightSidebar/SearchMessages/SearchMessagesTab.vue @@ -11,6 +11,7 @@ import type { SearchMessagePayload, UnifiedSearchResponse, UnifiedSearchResultEntry, + UnifiedSearchResultEntryWithRouterLink, } from '../../../types/index.ts' import { isCancel } from '@nextcloud/axios' @@ -36,7 +37,7 @@ import { useArrowNavigation } from '../../../composables/useArrowNavigation.js' import { useGetToken } from '../../../composables/useGetToken.ts' import { useIsInCall } from '../../../composables/useIsInCall.js' import { ATTENDEE } from '../../../constants.ts' -import { searchMessages } from '../../../services/coreService.ts' +import { searchMessagesInConversation } from '../../../services/coreService.ts' import { EventBus } from '../../../services/EventBus.ts' import CancelableRequest from '../../../utils/CancelableRequest.ts' @@ -53,15 +54,7 @@ const searchBox = ref | null>(null) const { initializeNavigation, resetNavigation } = useArrowNavigation(searchMessagesTab, searchBox) const isFocused = ref(false) -const searchResults = ref<(UnifiedSearchResultEntry & { - to: { - name: string - hash: string - params: { - token: string - } - } -})[]>([]) +const searchResults = ref([]) const searchText = ref('') const fromUser = ref(undefined) const sinceDate = ref(null) @@ -182,7 +175,7 @@ async function fetchSearchResults(isNew = true): Promise { cancelSearchFn() resetNavigation() - const { request, cancel } = CancelableRequest(searchMessages) + const { request, cancel } = CancelableRequest(searchMessagesInConversation) cancelSearchFn = cancel if (isNew) { diff --git a/src/services/coreService.ts b/src/services/coreService.ts index 34b66b095dc..ed9698468c7 100644 --- a/src/services/coreService.ts +++ b/src/services/coreService.ts @@ -104,7 +104,19 @@ async function deleteTaskById(id: number, options?: AxiosRequestConfig): Promise * @param params * @param options */ -async function searchMessages(params: SearchMessagePayload, options?: AxiosRequestConfig): UnifiedSearchResponse { +async function searchMessagesEverywhere(params: SearchMessagePayload, options?: AxiosRequestConfig): UnifiedSearchResponse { + return axios.get(generateOcsUrl('search/providers/talk-message/search'), { + ...options, + params, + }) +} + +/** + * + * @param params + * @param options + */ +async function searchMessagesInConversation(params: SearchMessagePayload, options?: AxiosRequestConfig): UnifiedSearchResponse { return axios.get(generateOcsUrl('search/providers/talk-message-current/search'), { ...options, params, @@ -116,5 +128,6 @@ export { deleteTaskById, getTaskById, getUserProfile, - searchMessages, + searchMessagesEverywhere, + searchMessagesInConversation, } diff --git a/src/types/core.ts b/src/types/core.ts index 56e89a9f2bd..1cd0bd58e52 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -117,6 +117,15 @@ export type SearchMessagePayload = operationsCore['unified_search-search']['para export type UnifiedSearchResultEntry = componentsCore['schemas']['UnifiedSearchResultEntry'] & { attributes: MessageSearchResultAttributes } +export type UnifiedSearchResultEntryWithRouterLink = UnifiedSearchResultEntry & { + to: { + name: string + hash: string + params: { + token: string + } + } +} export type UnifiedSearchResponse = ApiResponse Date: Mon, 17 Nov 2025 19:23:27 +0100 Subject: [PATCH 2/3] fix(SearchMessageItem): support compact style Signed-off-by: Maksim Sukharev --- .../SearchMessages/SearchMessageItem.vue | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/components/RightSidebar/SearchMessages/SearchMessageItem.vue b/src/components/RightSidebar/SearchMessages/SearchMessageItem.vue index 097e007731a..36bd2aa4b41 100644 --- a/src/components/RightSidebar/SearchMessages/SearchMessageItem.vue +++ b/src/components/RightSidebar/SearchMessages/SearchMessageItem.vue @@ -17,7 +17,7 @@ import NcListItem from '@nextcloud/vue/components/NcListItem' import CloseCircleOutline from 'vue-material-design-icons/CloseCircleOutline.vue' import AvatarWrapper from '../../AvatarWrapper/AvatarWrapper.vue' import ConversationIcon from '../../ConversationIcon.vue' -import { CONVERSATION } from '../../../constants.ts' +import { AVATAR, CONVERSATION } from '../../../constants.ts' import { EventBus } from '../../../services/EventBus.ts' import { useDashboardStore } from '../../../stores/dashboard.ts' import { formatDateTime } from '../../../utils/formattedTime.ts' @@ -34,6 +34,7 @@ const props = withDefaults(defineProps<{ timestamp: number messageParameters?: ChatMessage['messageParameters'] isReminder?: boolean + compact?: boolean }>(), { messageParameters: () => ({}), isReminder: false, @@ -88,6 +89,8 @@ function handleResultClick() { :to="to" :active="active" :title="richSubline" + :compact + class="search-message" forceMenu @click="handleResultClick"> - @@ -458,7 +479,10 @@ import { fetchNoteToSelfConversation, searchListedConversations, } from '../../services/conversationsService.ts' -import { autocompleteQuery } from '../../services/coreService.ts' +import { + autocompleteQuery, + searchMessagesEverywhere, +} from '../../services/coreService.ts' import { EventBus } from '../../services/EventBus.ts' import { talkBroadcastChannel } from '../../services/talkBroadcastChannel.js' import { useActorStore } from '../../stores/actor.ts' @@ -498,6 +522,17 @@ const FILTER_LABELS = { events: t('spreed', 'Meetings'), default: '', } +// Scopes to include in the search results. Message search is opt-in, as a +// global message search triggers an additional request on every search. +const SEARCH_FILTERS = ['conversations', 'people', 'messages'] +const SEARCH_FILTER_LABELS = { + conversations: t('spreed', 'Conversations'), + people: t('spreed', 'People'), + messages: t('spreed', 'Messages'), +} +const SEARCH_FILTERS_DEFAULT = ['conversations', 'people'] +// Number of message results to fetch per page in the global message search +const MESSAGE_SEARCH_LIMIT = 5 const SORT_LABELS = { // TRANSLATORS Navigation actions: sort conversations by recent activity / sort alphabetically SORTBY_HEADER: t('spreed', 'Sort conversations'), @@ -572,6 +607,7 @@ export default { const showArchived = ref(false) const showThreadsList = ref(false) const filters = ref(BrowserStorage.getItem('filterEnabled')?.split(',') ?? []) + const searchFilters = ref([...SEARCH_FILTERS_DEFAULT]) const federationStore = useFederationStore() const talkHashStore = useTalkHashStore() @@ -588,6 +624,7 @@ export default { resetNavigation, leftSidebar, filters, + searchFilters, searchBox, scroller, federationStore, @@ -606,6 +643,8 @@ export default { CONVERSATION, HOME_BUTTON_LABEL, FILTER_LABELS, + SEARCH_FILTERS, + SEARCH_FILTER_LABELS, SORT_LABELS, LIST_HEADING_ID, actorStore: useActorStore(), @@ -620,10 +659,15 @@ export default { searchText: '', searchResults: [], searchResultsListedConversations: [], + searchResultsMessages: [], searchResultsLoading: true, + searchMessagesCursor: null, + hasMoreMessages: false, + messagesLoadingMore: false, canStartConversations: getTalkConfig('local', 'conversations', 'can-create'), cancelSearchPossibleConversations: () => {}, cancelSearchListedConversations: () => {}, + cancelSearchMessages: () => {}, debounceFetchSearchResults: () => {}, debounceFetchConversations: () => {}, debounceHandleScroll: () => {}, @@ -862,6 +906,9 @@ export default { this.cancelSearchListedConversations() this.cancelSearchListedConversations = null + this.cancelSearchMessages() + this.cancelSearchMessages = null + if (this.refreshTimer) { clearInterval(this.refreshTimer) this.refreshTimer = null @@ -982,6 +1029,85 @@ export default { } }, + /** + * Map a raw unified search entry to a result with a router link + * + * @param {object} entry The unified search result entry + * @return {object} The entry extended with a `to` router link + */ + mapMessageResultEntry(entry) { + const threadId = (entry.attributes.threadId !== entry.attributes.messageId) ? entry.attributes.threadId : undefined + + return { + ...entry, + to: { + name: 'conversation', + hash: `#message_${entry.attributes.messageId}`, + params: { token: entry.attributes.conversation }, + query: { threadId }, + }, + } + }, + + async fetchMessagesEverywhere() { + // Reset pagination for a fresh search + this.searchMessagesCursor = null + this.hasMoreMessages = false + + try { + this.cancelSearchMessages('canceled') + const { request, cancel } = CancelableRequest(searchMessagesEverywhere) + this.cancelSearchMessages = cancel + + const response = await request({ + term: this.searchText, + limit: MESSAGE_SEARCH_LIMIT, + }) + const data = response?.data?.ocs?.data + this.searchResultsMessages = (data?.entries ?? []).map((entry) => this.mapMessageResultEntry(entry)) + this.searchMessagesCursor = data?.cursor ?? null + this.hasMoreMessages = Boolean(data?.isPaginated) && data?.cursor !== null + } catch (exception) { + if (CancelableRequest.isCancel(exception)) { + return + } + console.error('Error searching for messages', exception) + showError(t('spreed', 'An error occurred while performing the search')) + } + }, + + async fetchMoreMessages() { + if (!this.hasMoreMessages || this.messagesLoadingMore) { + return + } + + this.messagesLoadingMore = true + try { + this.cancelSearchMessages('canceled') + const { request, cancel } = CancelableRequest(searchMessagesEverywhere) + this.cancelSearchMessages = cancel + + const response = await request({ + term: this.searchText, + limit: MESSAGE_SEARCH_LIMIT, + cursor: this.searchMessagesCursor, + }) + const data = response?.data?.ocs?.data + const entries = (data?.entries ?? []).map((entry) => this.mapMessageResultEntry(entry)) + this.searchResultsMessages = [...this.searchResultsMessages, ...entries] + this.searchMessagesCursor = data?.cursor ?? null + this.hasMoreMessages = Boolean(data?.isPaginated) && data?.cursor !== null && entries.length > 0 + } catch (exception) { + if (CancelableRequest.isCancel(exception)) { + return + } + console.error('Error searching for more messages', exception) + showError(t('spreed', 'An error occurred while performing the search')) + } finally { + this.messagesLoadingMore = false + } + }, + async fetchSearchResults() { if (!this.isSearching) { return @@ -993,11 +1119,78 @@ export default { this.resetNavigation() this.searchResultsLoading = true - await Promise.all([this.fetchPossibleConversations(), this.fetchListedConversations()]) + + // Only search the scopes enabled via the filter chips. Message search + // is opt-in, as it triggers an additional request on every search. + const requests = [] + if (this.searchFilters.includes('people')) { + requests.push(this.fetchPossibleConversations()) + } else { + this.searchResults = [] + } + if (this.searchFilters.includes('conversations')) { + requests.push(this.fetchListedConversations()) + } else { + this.searchResultsListedConversations = [] + } + if (this.searchFilters.includes('messages')) { + requests.push(this.fetchMessagesEverywhere()) + } else { + this.searchResultsMessages = [] + } + await Promise.all(requests) + this.searchResultsLoading = false this.initializeNavigation() }, + handleSearchFilter(filter) { + if (this.searchFilters.includes(filter)) { + this.searchFilters = this.searchFilters.filter((f) => f !== filter) + this.clearSearchFilterResults(filter) + } else { + this.searchFilters = [...this.searchFilters, filter] + if (this.isSearching) { + this.fetchSearchFilterResults(filter) + } + } + }, + + async fetchSearchFilterResults(filter) { + this.resetNavigation() + switch (filter) { + case 'people': + await this.fetchPossibleConversations() + break + case 'conversations': + await this.fetchListedConversations() + break + case 'messages': + await this.fetchMessagesEverywhere() + break + } + this.initializeNavigation() + }, + + clearSearchFilterResults(filter) { + switch (filter) { + case 'people': + this.cancelSearchPossibleConversations('canceled') + this.searchResults = [] + break + case 'conversations': + this.cancelSearchListedConversations('canceled') + this.searchResultsListedConversations = [] + break + case 'messages': + this.cancelSearchMessages('canceled') + this.searchResultsMessages = [] + this.searchMessagesCursor = null + this.hasMoreMessages = false + break + } + }, + /** * Create a new conversation with the selected user * or bring up the dialog to create a new group/circle conversation @@ -1060,6 +1253,12 @@ export default { if (this.cancelSearchListedConversations) { this.cancelSearchListedConversations() } + if (this.cancelSearchMessages) { + this.cancelSearchMessages() + } + this.searchMessagesCursor = null + this.hasMoreMessages = false + this.messagesLoadingMore = false }, showSettings() { @@ -1334,6 +1533,17 @@ export default { gap: var(--default-grid-baseline); } +.conversations__search-filters { + display: flex; + flex-wrap: wrap; + gap: var(--default-grid-baseline); + margin-block-start: var(--default-grid-baseline); + + .search-filter-chip { + cursor: pointer; + } +} + .archived-conversations-bubble { margin-inline: var(--default-grid-baseline); color: var(--color-primary-element); diff --git a/src/components/LeftSidebar/SearchConversationsResults/SearchConversationsResults.vue b/src/components/LeftSidebar/SearchConversationsResults/SearchConversationsResults.vue index 9162cd1244a..e67e8d0bbbc 100644 --- a/src/components/LeftSidebar/SearchConversationsResults/SearchConversationsResults.vue +++ b/src/components/LeftSidebar/SearchConversationsResults/SearchConversationsResults.vue @@ -4,16 +4,23 @@ -->