Browse Source
Merge pull request #14125 from nextcloud/feat/11130/search-messages
Merge pull request #14125 from nextcloud/feat/11130/search-messages
feat: Add search message tab to the right sidebarpull/14120/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 632 additions and 88 deletions
-
12src/components/MessagesList/MessagesList.vue
-
213src/components/RightSidebar/RightSidebar.vue
-
439src/components/RightSidebar/SearchMessages/SearchMessagesTab.vue
-
10src/services/coreService.ts
-
46src/types/index.ts
@ -0,0 +1,439 @@ |
|||
<!-- |
|||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors |
|||
- SPDX-License-Identifier: AGPL-3.0-or-later |
|||
--> |
|||
|
|||
<script setup lang="ts"> |
|||
import debounce from 'debounce' |
|||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' |
|||
import type { Route } from 'vue-router' |
|||
|
|||
import IconCalendarRange from 'vue-material-design-icons/CalendarRange.vue' |
|||
import IconFilter from 'vue-material-design-icons/Filter.vue' |
|||
import IconMessageOutline from 'vue-material-design-icons/MessageOutline.vue' |
|||
|
|||
import { showError } from '@nextcloud/dialogs' |
|||
import { t } from '@nextcloud/l10n' |
|||
|
|||
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' |
|||
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' |
|||
import NcChip from '@nextcloud/vue/dist/Components/NcChip.js' |
|||
import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js' |
|||
import NcDateTimePickerNative from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js' |
|||
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' |
|||
import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js' |
|||
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' |
|||
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' |
|||
|
|||
import AvatarWrapper from '../../AvatarWrapper/AvatarWrapper.vue' |
|||
import SearchBox from '../../UIShared/SearchBox.vue' |
|||
import TransitionWrapper from '../../UIShared/TransitionWrapper.vue' |
|||
|
|||
import { useIsInCall } from '../../../composables/useIsInCall.js' |
|||
import { useStore } from '../../../composables/useStore.js' |
|||
import { ATTENDEE } from '../../../constants.js' |
|||
import { searchMessages } from '../../../services/coreService.ts' |
|||
import { EventBus } from '../../../services/EventBus.ts' |
|||
import { |
|||
CoreUnifiedSearchResultEntry, |
|||
UserFilterObject, |
|||
SearchMessagePayload, |
|||
UnifiedSearchResponse, |
|||
} from '../../../types/index.ts' |
|||
import CancelableRequest from '../../../utils/cancelableRequest.js' |
|||
|
|||
const emit = defineEmits<{ |
|||
(event: 'close'): void |
|||
}>() |
|||
|
|||
const searchBox = ref(null) |
|||
const isFocused = ref(false) |
|||
const searchResults = ref<(CoreUnifiedSearchResultEntry & |
|||
{ |
|||
to: { |
|||
name: string; |
|||
hash: string; |
|||
params: { |
|||
token: string; |
|||
skipLeaveWarning: boolean; |
|||
}; |
|||
} |
|||
})[]>([]) |
|||
const searchText = ref('') |
|||
const fromUser = ref<UserFilterObject | null>(null) |
|||
const sinceDate = ref<Date | null>(null) |
|||
const untilDate = ref<Date | null>(null) |
|||
const searchLimit = ref(10) |
|||
const searchCursor = ref<number | string | null>(0) |
|||
const searchDetailsOpened = ref(false) |
|||
const isFetchingResults = ref(false) |
|||
const isSearchExhausted = ref(false) |
|||
|
|||
const store = useStore() |
|||
const isInCall = useIsInCall() |
|||
|
|||
const token = computed(() => store.getters.getToken()) |
|||
const participantsInitialised = computed(() => store.getters.participantsInitialised(token.value)) |
|||
const participants = computed<UserFilterObject>(() => { |
|||
return store.getters.participantsList(token.value) |
|||
.filter(({ actorType }) => actorType === ATTENDEE.ACTOR_TYPE.USERS) // FIXME: federated users are not supported by the search provider |
|||
.map(({ actorId, displayName, actorType }: { actorId: string; displayName: string; actorType: string}) => ({ |
|||
id: actorId, |
|||
displayName, |
|||
isNoUser: actorType !== 'users', |
|||
user: actorId, |
|||
disableMenu: true, |
|||
showUserStatus: false, |
|||
})) |
|||
}) |
|||
const canLoadMore = computed(() => !isSearchExhausted.value && !isFetchingResults.value && searchCursor.value !== 0) |
|||
const hasFilter = computed(() => fromUser.value || sinceDate.value || untilDate.value) |
|||
|
|||
onMounted(() => { |
|||
EventBus.on('route-change', onRouteChange) |
|||
}) |
|||
|
|||
onBeforeUnmount(() => { |
|||
EventBus.off('route-change', onRouteChange) |
|||
abortSearch() |
|||
}) |
|||
|
|||
const onRouteChange = ({ from, to }: { from: Route, to: Route }): void => { |
|||
if (to.name !== 'conversation' || from.params.token !== to.params.token || (to.hash && isInCall.value)) { |
|||
abortSearch() |
|||
emit('close') |
|||
} |
|||
} |
|||
|
|||
watch(searchText, (value) => { |
|||
if (value.trim().length === 0) { |
|||
searchResults.value = [] |
|||
searchCursor.value = 0 |
|||
isSearchExhausted.value = false |
|||
} |
|||
}) |
|||
|
|||
/** |
|||
* Cancel search and cleanup the search fields and results. |
|||
*/ |
|||
function abortSearch() { |
|||
cancelSearchFn() |
|||
searchText.value = '' |
|||
fromUser.value = null |
|||
sinceDate.value = null |
|||
untilDate.value = null |
|||
searchDetailsOpened.value = false |
|||
searchResults.value = [] |
|||
searchCursor.value = 0 |
|||
} |
|||
|
|||
/** |
|||
* Continue fetching more search results |
|||
*/ |
|||
function loadMore() { |
|||
return fetchSearchResults(false) |
|||
} |
|||
|
|||
/** |
|||
* |
|||
*/ |
|||
function fetchNewSearchResult() { |
|||
return fetchSearchResults(true) |
|||
} |
|||
|
|||
let cancelSearchFn = () => {} |
|||
|
|||
type SearchMessageCancelableRequest = { |
|||
request: (payload: SearchMessagePayload) => UnifiedSearchResponse, |
|||
cancel: () => void, |
|||
} |
|||
|
|||
/** |
|||
* @param [isNew=true] Is it a new search (search parameters changed)? |
|||
* Fetch the search results from the server |
|||
*/ |
|||
async function fetchSearchResults(isNew = true) { |
|||
const term = searchText.value.trim() |
|||
// Don't search if the search text is empty |
|||
if (term.length === 0) { |
|||
return |
|||
} |
|||
|
|||
isFetchingResults.value = true |
|||
|
|||
try { |
|||
// cancel the previous search request |
|||
cancelSearchFn() |
|||
|
|||
const { request, cancel } = CancelableRequest(searchMessages) as SearchMessageCancelableRequest |
|||
cancelSearchFn = cancel |
|||
|
|||
if (isNew) { |
|||
searchCursor.value = 0 |
|||
} |
|||
if (searchCursor.value === 0) { |
|||
searchResults.value = [] |
|||
} |
|||
|
|||
if (term.length === 0 && !fromUser.value && !sinceDate.value && !untilDate.value) { |
|||
return |
|||
} |
|||
const response = await request({ |
|||
term, |
|||
person: fromUser.value?.id, |
|||
since: !isNaN(sinceDate.value) ? sinceDate.value?.toISOString() : null, |
|||
until: !isNaN(untilDate.value) ? untilDate.value?.toISOString() : null, |
|||
limit: searchLimit.value, |
|||
cursor: searchCursor.value || null, |
|||
from: `/call/${token.value}`, |
|||
}) |
|||
|
|||
const data = response?.data?.ocs?.data |
|||
if (data?.entries.length > 0) { |
|||
let entries = data?.entries |
|||
|
|||
isSearchExhausted.value = entries.length < searchLimit.value |
|||
searchCursor.value = data.cursor |
|||
|
|||
// FIXME: remove the filter after the person filter is fixed on the server |
|||
if (fromUser.value) { |
|||
entries = entries.filter((entry) => entry.attributes.actorId === fromUser.value?.id) |
|||
if (entries.length === 0 && !isSearchExhausted.value) { |
|||
return await loadMore() |
|||
} |
|||
} |
|||
|
|||
searchResults.value = searchResults.value.concat(entries.map((entry : CoreUnifiedSearchResultEntry) => { |
|||
return { |
|||
...entry, |
|||
to: { |
|||
name: 'conversation', |
|||
hash: `#message_${entry.attributes.messageId}`, |
|||
params: { |
|||
token: entry.attributes.conversation, |
|||
skipLeaveWarning: true |
|||
} |
|||
} |
|||
} |
|||
}) |
|||
) |
|||
} |
|||
} catch (exception) { |
|||
if (CancelableRequest.isCancel(exception)) { |
|||
return |
|||
} |
|||
console.error('Error searching for messages', exception) |
|||
showError(t('spreed', 'An error occurred while performing the search')) |
|||
} finally { |
|||
isFetchingResults.value = false |
|||
} |
|||
} |
|||
|
|||
const debounceFetchSearchResults = debounce(fetchNewSearchResult, 250) |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="search-messages-tab"> |
|||
<div class="search-form"> |
|||
<div class="search-form__main"> |
|||
<div class="search-form__search-box-wrapper"> |
|||
<SearchBox ref="searchBox" |
|||
:value.sync="searchText" |
|||
:placeholder-text="t('spreed', 'Search messages …')" |
|||
:is-focused.sync="isFocused" |
|||
@input="debounceFetchSearchResults" /> |
|||
<NcButton :pressed.sync="searchDetailsOpened" |
|||
:aria-label="t('spreed', 'Search options')" |
|||
:title="t('spreed', 'Search options')" |
|||
type="tertiary-no-background"> |
|||
<template #icon> |
|||
<IconFilter :size="15" /> |
|||
</template> |
|||
</NcButton> |
|||
</div> |
|||
<TransitionWrapper name="radial-reveal"> |
|||
<div v-show="searchDetailsOpened" class="search-form__search-detail"> |
|||
<NcSelect v-model="fromUser" |
|||
class="search-form__search-detail__from-user" |
|||
:aria-label-combobox="t('spreed', 'From User')" |
|||
:placeholder="t('spreed', 'From User')" |
|||
user-select |
|||
:loading="!participantsInitialised" |
|||
:options="participants" |
|||
@update:modelValue="debounceFetchSearchResults" /> |
|||
<div class="search-form__search-detail__date-picker-wrapper"> |
|||
<NcDateTimePickerNative id="search-form__search-detail__date-picker--since" |
|||
v-model="sinceDate" |
|||
class="search-form__search-detail__date-picker" |
|||
format="YYYY-MM-DD" |
|||
type="date" |
|||
:step="1" |
|||
:max="new Date()" |
|||
:aria-label="t('spreed', 'Since')" |
|||
:label="t('spreed', 'Since')" |
|||
@update:modelValue="debounceFetchSearchResults" /> |
|||
<NcDateTimePickerNative id="search-form__search-detail__date-picker--until" |
|||
v-model="untilDate" |
|||
class="search-form__search-detail__date-picker" |
|||
format="YYYY-MM-DD" |
|||
type="date" |
|||
:max="new Date()" |
|||
:aria-label="t('spreed', 'Until')" |
|||
:label="t('spreed', 'Until')" |
|||
:minute-step="1" |
|||
@update:modelValue="debounceFetchSearchResults" /> |
|||
</div> |
|||
</div> |
|||
</TransitionWrapper> |
|||
<TransitionWrapper name="fade"> |
|||
<div v-show="hasFilter && !searchDetailsOpened" |
|||
class="search-form__search-bubbles"> |
|||
<NcChip v-if="fromUser" |
|||
type="tertiary" |
|||
:text="fromUser.displayName" |
|||
@close="fromUser = null"> |
|||
<template #icon> |
|||
<NcAvatar :size="24" |
|||
:user="fromUser.id" |
|||
:display-name="fromUser.displayName" |
|||
:show-user-status="false" /> |
|||
</template> |
|||
</NcChip> |
|||
<NcChip v-if="sinceDate" |
|||
type="tertiary" |
|||
:text="t('spreed', 'Since') + ' ' + sinceDate?.toLocaleDateString()" |
|||
@close="sinceDate = null"> |
|||
<template #icon> |
|||
<IconCalendarRange :size="15" /> |
|||
</template> |
|||
</NcChip> |
|||
<NcChip v-if="untilDate" |
|||
type="tertiary" |
|||
:text="t('spreed', 'Until') + ' ' + untilDate?.toLocaleDateString()" |
|||
@close="untilDate = null"> |
|||
<template #icon> |
|||
<IconCalendarRange :size="15" /> |
|||
</template> |
|||
</NcChip> |
|||
</div> |
|||
</TransitionWrapper> |
|||
</div> |
|||
</div> |
|||
<div class="search-results"> |
|||
<template v-if="searchResults.length !== 0"> |
|||
<NcListItem v-for="item of searchResults" |
|||
:key="`message_${item.attributes.messageId}`" |
|||
:data-nav-id="`message_${item.attributes.messageId}`" |
|||
:name="item.title" |
|||
:to="item.to" |
|||
:v-tooltip="item.subline"> |
|||
<template #icon> |
|||
<AvatarWrapper :id="item.attributes.actorId" |
|||
:name="item.title" |
|||
:source="item.attributes.actorType" |
|||
:disable-menu="true" |
|||
:token="item.attributes.conversation" /> |
|||
</template> |
|||
<template #subname> |
|||
{{ item.subline }} |
|||
</template> |
|||
<template #details> |
|||
<NcDateTime :timestamp="parseInt(item.attributes.timestamp) * 1000" |
|||
class="search-results__date" |
|||
relative-time="narrow" |
|||
ignore-seconds /> |
|||
</template> |
|||
</NcListItem> |
|||
</template> |
|||
<NcEmptyContent v-else-if="!isFetchingResults && searchText.trim().length !== 0" |
|||
class="search-results__empty" |
|||
:name="t('spreed', 'No results found')"> |
|||
<template #icon> |
|||
<IconMessageOutline :size="64" /> |
|||
</template> |
|||
</NcEmptyContent> |
|||
<template v-if="canLoadMore"> |
|||
<NcButton wide type="tertiary" @click="fetchSearchResults(false)"> |
|||
{{ t('spreed', 'Load more results') }} |
|||
</NcButton> |
|||
</template> |
|||
<template v-if="isFetchingResults"> |
|||
<NcLoadingIcon class="search-results__loading" /> |
|||
</template> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style lang="scss" scoped> |
|||
.search-messages-tab { |
|||
display: flex; |
|||
flex-direction: column; |
|||
height: 100%; |
|||
} |
|||
|
|||
.search-form { |
|||
display: flex; |
|||
flex-direction: column; |
|||
padding-bottom: var(--default-grid-baseline); |
|||
|
|||
&__search-box-wrapper { |
|||
display: flex; |
|||
gap: var(--default-grid-baseline); |
|||
} |
|||
|
|||
&__main { |
|||
display: flex; |
|||
flex-direction: column; |
|||
flex: 1; |
|||
} |
|||
|
|||
&__search-detail { |
|||
display: flex; |
|||
flex-direction: column; |
|||
width: 100%; |
|||
margin-top: calc(var(--default-grid-baseline) * 4); |
|||
|
|||
&__from-user { |
|||
margin-top: 8px; |
|||
:deep(.vs__dropdown-toggle) { |
|||
overflow-y: clip; |
|||
} |
|||
} |
|||
|
|||
&__date-picker { |
|||
width: 100%; |
|||
min-width: 100px; |
|||
|
|||
&-wrapper { |
|||
display: flex; |
|||
gap: var(--default-grid-baseline); |
|||
} |
|||
} |
|||
} |
|||
|
|||
&__search-bubbles { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
gap: var(--default-grid-baseline); |
|||
margin-top: var(--default-grid-baseline); |
|||
} |
|||
} |
|||
|
|||
.search-results { |
|||
transition: all 0.15s ease; |
|||
height: 100%; |
|||
overflow-y: auto; |
|||
|
|||
&__date { |
|||
font-size: x-small; |
|||
} |
|||
|
|||
&__loading { |
|||
height: var(--default-clickable-area); |
|||
} |
|||
|
|||
&__empty { |
|||
height: 100%; |
|||
} |
|||
} |
|||
</style> |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue