diff --git a/src/components/LeftSidebar/LeftSidebar.vue b/src/components/LeftSidebar/LeftSidebar.vue
index 1953ceb4864..0eb55fca686 100644
--- a/src/components/LeftSidebar/LeftSidebar.vue
+++ b/src/components/LeftSidebar/LeftSidebar.vue
@@ -230,6 +230,22 @@
:text="FILTER_LABELS[filter]"
@close="handleFilter(filter)" />
+
+
+
+
@@ -356,10 +372,15 @@
class="scroller"
:searchText="searchText"
:searchResultsLoading="searchResultsLoading"
+ :searchFilters="searchFilters"
:conversationsList="conversationsList"
:searchResults="searchResults"
+ :searchResultsMessages="searchResultsMessages"
:searchResultsListedConversations="searchResultsListedConversations"
+ :hasMoreMessages="hasMoreMessages"
+ :messagesLoadingMore="messagesLoadingMore"
@abortSearch="abortSearch"
+ @loadMoreMessages="fetchMoreMessages"
@createNewConversation="createConversation"
@createAndJoinConversation="createAndJoinConversation" />
@@ -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 @@
-->