You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

1091 lines
34 KiB

<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcAppNavigation ref="leftSidebar" :aria-label="t('spreed', 'Conversation list')">
<template #search>
<div class="new-conversation">
<div class="conversations-search"
:class="{ 'conversations-search--expanded': isSearching }">
<SearchBox ref="searchBox"
v-model:value="searchText"
v-model:is-focused="isFocused"
:list-ref="[scroller, searchResults]"
@input="debounceFetchSearchResults"
@abort-search="abortSearch" />
</div>
<TransitionWrapper name="radial-reveal">
<!-- Filters -->
<NcActions v-show="searchText === ''"
:variant="isFiltered ? 'secondary' : 'tertiary'"
class="filters"
:class="{ 'hidden-visually': isSearching }">
<template #icon>
<IconFilterOutline :size="20" />
</template>
<NcActionCaption :name="t('spreed', 'Filter conversations by')" />
<NcActionButton close-after-click
type="checkbox"
:model-value="filters.includes('mentions')"
@click="handleFilter('mentions')">
<template #icon>
<IconAt :size="20" />
</template>
{{ t('spreed', 'Unread mentions') }}
</NcActionButton>
<NcActionButton close-after-click
type="checkbox"
:model-value="filters.includes('unread')"
@click="handleFilter('unread')">
<template #icon>
<IconMessageBadgeOutline :size="20" />
</template>
{{ t('spreed', 'Unread messages') }}
</NcActionButton>
<NcActionButton close-after-click
type="checkbox"
:model-value="filters.includes('events')"
@click="handleFilter('events')">
<template #icon>
<IconCalendarBlankOutline :size="20" />
</template>
{{ t('spreed', 'Meeting conversations') }}
</NcActionButton>
<NcActionButton v-if="isFiltered"
close-after-click
class="filter-actions__clearbutton"
@click="handleFilter(null)">
<template #icon>
<IconFilterRemoveOutline :size="20" />
</template>
{{ t('spreed', 'Clear filters') }}
</NcActionButton>
</NcActions>
</TransitionWrapper>
<!-- Actions -->
<TransitionWrapper name="radial-reveal">
<NcActions v-show="searchText === ''"
class="actions"
:class="{ 'hidden-visually': isSearching }">
<template #icon>
<IconChatPlusOutline :size="20" />
</template>
<NcActionButton v-if="canStartConversations"
close-after-click
@click="showModalNewConversation">
<template #icon>
<IconPlus :size="20" />
</template>
{{ t('spreed', 'Create a new conversation') }}
</NcActionButton>
<NcActionButton v-if="canNoteToSelf && !hasNoteToSelf"
close-after-click
@click="restoreNoteToSelfConversation">
<template #icon>
<IconNoteEditOutline :size="20" />
</template>
{{ t('spreed', 'New personal note') }}
</NcActionButton>
<NcActionButton close-after-click
@click="showModalListConversations">
<template #icon>
<IconFormatListBulleted :size="20" />
</template>
{{ t('spreed', 'Join open conversations') }}
</NcActionButton>
<NcActionButton v-if="canModerateSipDialOut"
close-after-click
@click="showModalCallPhoneDialog">
<template #icon>
<IconPhoneOutline :size="20" />
</template>
{{ t('spreed', 'Call a phone number') }}
</NcActionButton>
</NcActions>
</TransitionWrapper>
<!-- All open conversations list -->
<OpenConversationsList ref="openConversationsList" />
<!-- New Conversation dialog -->
<NewConversationDialog ref="newConversationDialog" :can-moderate-sip-dial-out="canModerateSipDialOut" />
<!-- New phone (SIP dial-out) dialog -->
<CallPhoneDialog v-if="canModerateSipDialOut" ref="callPhoneDialog" />
<!-- New Pending Invitations dialog -->
<InvitationHandler v-if="pendingInvitationsCount" ref="invitationHandler" />
</div>
<TransitionWrapper class="conversations__filters"
name="zoom"
tag="div"
group>
<NcChip v-for="filter in filters"
:key="filter"
:text="FILTER_LABELS[filter]"
@close="handleFilter(filter)" />
</TransitionWrapper>
<template v-if="!isSearching">
<NcAppNavigationItem
class="navigation-item"
:to="{ name: 'root' }"
:name="t('spreed', 'Talk home')"
@click="refreshTalkDashboard">
<template #icon>
<IconHomeOutline :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem v-if="!isSearching"
class="navigation-item"
:name="showThreadsList ? t('spreed', 'Back to conversations') : t('spreed', 'Followed threads')"
@click.prevent="showThreadsList = !showThreadsList">
<template #icon>
<IconArrowLeft v-if="showThreadsList" class="bidirectional-icon" :size="20" />
<IconForumOutline v-else :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem v-if="pendingInvitationsCount"
class="navigation-item"
:name="t('spreed', 'Pending invitations')"
@click.prevent="showInvitationHandler">
<template #icon>
<IconAccountMultiplePlusOutline :size="20" />
</template>
<template #counter>
<NcCounterBubble type="highlighted" :count="pendingInvitationsCount" />
</template>
</NcAppNavigationItem>
</template>
</template>
<template #list>
<!-- Conversations List -->
<template v-if="!isSearching">
<NcEmptyContent v-if="conversationsInitialised && filteredConversationsList.length === 0"
:name="emptyContentLabel"
:description="emptyContentDescription">
<template #icon>
<IconAt v-if="filters.length === 1 && filters[0] === 'mentions'" :size="64" />
<IconMessageBadgeOutline v-else-if="filters.length === 1 && filters[0] === 'unread'" :size="64" />
<IconArchiveOutline v-else-if="showArchived" :size="64" />
<IconForumOutline v-else-if="showThreadsList" :size="64" />
<IconMessageOutline v-else :size="64" />
</template>
<template #action>
<NcButton v-if="isFiltered" @click="handleFilter(null)">
<template #icon>
<IconFilterRemoveOutline :size="20" />
</template>
{{ t('spreed', 'Clear filter') }}
</NcButton>
</template>
</NcEmptyContent>
<ul v-if="showThreadsList" class="threads-tab__list">
<ThreadItem
v-for="thread of followedThreads"
:key="`thread_${thread.thread.id}`"
:thread="thread" />
</ul>
<ConversationsListVirtual
v-else
v-show="filteredConversationsList.length > 0"
ref="scroller"
:conversations="filteredConversationsList"
:loading="!conversationsInitialised"
:compact="isCompact"
class="scroller"
@scroll="debounceHandleScroll" />
<NcButton v-if="!showThreadsList && !preventFindingUnread && lastUnreadMentionBelowViewportIndex !== null"
class="unread-mention-button"
variant="primary"
@click="scrollBottomUnread">
{{ t('spreed', 'Unread mentions') }}
</NcButton>
</template>
<!-- Search results -->
<SearchConversationsResults v-else
ref="searchResults"
class="scroller"
:search-text="searchText"
:contacts-loading="contactsLoading"
:conversations-list="conversationsList"
:search-results="searchResults"
:search-results-listed-conversations="searchResultsListedConversations"
@abort-search="abortSearch"
@create-new-conversation="createConversation"
@create-and-join-conversation="createAndJoinConversation" />
</template>
<template #footer>
<div class="left-sidebar__settings-button-container">
<template v-if="!isSearching && supportsArchive">
<NcButton v-if="showArchived"
variant="tertiary"
wide
@click="showArchived = false">
<template #icon>
<IconArrowLeft class="bidirectional-icon" :size="20" />
</template>
{{ t('spreed', 'Back to conversations') }}
</NcButton>
<NcButton v-else-if="archivedConversationsList.length"
variant="tertiary"
wide
@click="showArchived = true">
<template #icon>
<IconArchiveOutline :size="20" />
</template>
{{ t('spreed', 'Archived conversations') }}
<span v-if="showArchivedConversationsBubble" class="left-sidebar__settings-button-bubble">
</span>
</NcButton>
</template>
<NcButton variant="tertiary" wide @click="showSettings">
<template #icon>
<IconCogOutline :size="20" />
</template>
{{ t('spreed', 'Talk settings') }}
</NcButton>
</div>
</template>
</NcAppNavigation>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile'
import debounce from 'debounce'
import { ref } from 'vue'
import { START_LOCATION } from 'vue-router'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionCaption from '@nextcloud/vue/components/NcActionCaption'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcChip from '@nextcloud/vue/components/NcChip'
import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import IconAccountMultiplePlusOutline from 'vue-material-design-icons/AccountMultiplePlusOutline.vue'
import IconArchiveOutline from 'vue-material-design-icons/ArchiveOutline.vue'
import IconArrowLeft from 'vue-material-design-icons/ArrowLeft.vue'
import IconAt from 'vue-material-design-icons/At.vue'
import IconCalendarBlankOutline from 'vue-material-design-icons/CalendarBlankOutline.vue'
import IconChatPlusOutline from 'vue-material-design-icons/ChatPlusOutline.vue'
import IconCogOutline from 'vue-material-design-icons/CogOutline.vue'
import IconFilterOutline from 'vue-material-design-icons/FilterOutline.vue'
import IconFilterRemoveOutline from 'vue-material-design-icons/FilterRemoveOutline.vue'
import IconFormatListBulleted from 'vue-material-design-icons/FormatListBulleted.vue'
import IconForumOutline from 'vue-material-design-icons/ForumOutline.vue'
import IconHomeOutline from 'vue-material-design-icons/HomeOutline.vue'
import IconMessageBadgeOutline from 'vue-material-design-icons/MessageBadgeOutline.vue'
import IconMessageOutline from 'vue-material-design-icons/MessageOutline.vue'
import IconNoteEditOutline from 'vue-material-design-icons/NoteEditOutline.vue'
import IconPhoneOutline from 'vue-material-design-icons/PhoneOutline.vue'
import IconPlus from 'vue-material-design-icons/Plus.vue'
import NewConversationDialog from '../NewConversationDialog/NewConversationDialog.vue'
import ThreadItem from '../RightSidebar/Threads/ThreadItem.vue'
import SearchBox from '../UIShared/SearchBox.vue'
import TransitionWrapper from '../UIShared/TransitionWrapper.vue'
import CallPhoneDialog from './CallPhoneDialog/CallPhoneDialog.vue'
import ConversationsListVirtual from './ConversationsList/ConversationsListVirtual.vue'
import InvitationHandler from './InvitationHandler.vue'
import OpenConversationsList from './OpenConversationsList/OpenConversationsList.vue'
import SearchConversationsResults from './SearchConversationsResults/SearchConversationsResults.vue'
import { useArrowNavigation } from '../../composables/useArrowNavigation.js'
import { useGetToken } from '../../composables/useGetToken.ts'
import { ATTENDEE, CONVERSATION } from '../../constants.ts'
import BrowserStorage from '../../services/BrowserStorage.js'
import { getTalkConfig, hasTalkFeature } from '../../services/CapabilitiesManager.ts'
import {
createLegacyConversation,
fetchNoteToSelfConversation,
searchListedConversations,
} from '../../services/conversationsService.ts'
import { autocompleteQuery } from '../../services/coreService.ts'
import { EventBus } from '../../services/EventBus.ts'
import { talkBroadcastChannel } from '../../services/talkBroadcastChannel.js'
import { useActorStore } from '../../stores/actor.ts'
import { useChatExtrasStore } from '../../stores/chatExtras.ts'
import { useFederationStore } from '../../stores/federation.ts'
import { useSettingsStore } from '../../stores/settings.js'
import { useTalkHashStore } from '../../stores/talkHash.js'
import { useTokenStore } from '../../stores/token.ts'
import CancelableRequest from '../../utils/cancelableRequest.js'
import { filterConversation, hasCall, hasUnreadMentions, shouldIncludeArchived } from '../../utils/conversation.ts'
import { requestTabLeadership } from '../../utils/requestTabLeadership.js'
const isFederationEnabled = getTalkConfig('local', 'federation', 'enabled')
const canModerateSipDialOut = hasTalkFeature('local', 'sip-support-dialout')
&& getTalkConfig('local', 'call', 'sip-enabled')
&& getTalkConfig('local', 'call', 'sip-dialout-enabled')
&& getTalkConfig('local', 'call', 'can-enable-sip')
const canNoteToSelf = hasTalkFeature('local', 'note-to-self')
const supportsArchive = hasTalkFeature('local', 'archived-conversations-v2')
const FILTER_LABELS = {
unread: t('spreed', 'Unread'),
mentions: t('spreed', 'Mentions'),
events: t('spreed', 'Meetings'),
default: '',
}
let actualizeDataTimeout = null
export default {
name: 'LeftSidebar',
components: {
ThreadItem,
CallPhoneDialog,
InvitationHandler,
NcAppNavigation,
NcAppNavigationItem,
NcButton,
NcCounterBubble,
NcChip,
SearchBox,
NewConversationDialog,
OpenConversationsList,
NcActions,
NcActionButton,
NcActionCaption,
TransitionWrapper,
ConversationsListVirtual,
SearchConversationsResults,
// Icons
IconAccountMultiplePlusOutline,
IconAt,
IconMessageBadgeOutline,
IconMessageOutline,
IconFilterOutline,
IconFilterRemoveOutline,
IconArchiveOutline,
IconArrowLeft,
IconCalendarBlankOutline,
IconForumOutline,
IconHomeOutline,
IconPhoneOutline,
IconPlus,
IconChatPlusOutline,
IconCogOutline,
IconFormatListBulleted,
IconNoteEditOutline,
NcEmptyContent,
},
setup() {
const leftSidebar = ref(null)
const searchBox = ref(null)
const scroller = ref(null)
const showArchived = ref(false)
const showThreadsList = ref(false)
const filters = ref(BrowserStorage.getItem('filterEnabled')?.split(',') ?? [])
const federationStore = useFederationStore()
const talkHashStore = useTalkHashStore()
const settingsStore = useSettingsStore()
const { initializeNavigation, resetNavigation } = useArrowNavigation(leftSidebar, searchBox)
const isMobile = useIsMobile()
return {
token: useGetToken(),
initializeNavigation,
resetNavigation,
leftSidebar,
filters,
searchBox,
scroller,
federationStore,
talkHashStore,
isMobile,
canModerateSipDialOut,
canNoteToSelf,
supportsArchive,
showArchived,
showThreadsList,
settingsStore,
FILTER_LABELS,
actorStore: useActorStore(),
chatExtrasStore: useChatExtrasStore(),
tokenStore: useTokenStore(),
}
},
data() {
return {
searchText: '',
searchResults: [],
searchResultsListedConversations: [],
contactsLoading: false,
listedConversationsLoading: false,
canStartConversations: getTalkConfig('local', 'conversations', 'can-create'),
cancelSearchPossibleConversations: () => {},
cancelSearchListedConversations: () => {},
debounceFetchSearchResults: () => {},
debounceFetchConversations: () => {},
debounceHandleScroll: () => {},
refreshTimer: null,
/**
* @type {number|null}
*/
lastUnreadMentionBelowViewportIndex: null,
preventFindingUnread: false,
roomListModifiedBefore: 0,
forceFullRoomListRefreshAfterXLoops: 0,
isFetchingConversations: false,
isCurrentTabLeader: false,
isFocused: false,
isNavigating: false,
}
},
computed: {
conversationsList() {
return this.$store.getters.conversationsList
},
emptyContentLabel() {
if (this.isFiltered) {
return t('spreed', 'No matches found')
} else {
return t('spreed', 'No conversations found')
}
},
emptyContentDescription() {
if (this.showArchived) {
return t('spreed', 'You have no archived conversations.')
} else if (this.showThreadsList) {
return t('spreed', 'You have no followed threads.')
}
if (this.filters.length === 1 && this.filters[0] === 'mentions') {
return t('spreed', 'You have no unread mentions.')
} else if (this.filters.length === 1 && this.filters[0] === 'unread') {
return t('spreed', 'You have no unread messages.')
} else {
return ''
}
},
archivedConversationsList() {
return this.$store.getters.archivedConversationsList
},
showArchivedConversationsBubble() {
return this.archivedConversationsList
.some((conversation) => hasUnreadMentions(conversation) || hasCall(conversation))
},
filteredConversationsList() {
if (this.isFocused) {
return this.conversationsList.filter((conversation) => shouldIncludeArchived(conversation, this.showArchived))
}
let validConversationsCount = 0
const filteredConversations = this.conversationsList.filter((conversation) => {
const conversationIsValid = filterConversation(conversation, this.filters)
if (conversationIsValid) {
validConversationsCount++
}
return shouldIncludeArchived(conversation, this.showArchived)
&& (conversationIsValid || hasCall(conversation) || conversation.token === this.token)
})
// return empty if it only includes the current conversation without any flags
return validConversationsCount === 0 && !this.isNavigating ? [] : filteredConversations
},
followedThreads() {
return this.chatExtrasStore.getSubscribedThreadsList
},
isSearching() {
return this.searchText !== ''
},
hasNoteToSelf() {
return this.conversationsList.find((conversation) => conversation.type === CONVERSATION.TYPE.NOTE_TO_SELF)
},
pendingInvitationsCount() {
return isFederationEnabled
? this.federationStore.pendingSharesCount
: 0
},
isCompact() {
return this.settingsStore.conversationsListStyle === CONVERSATION.LIST_STYLE.COMPACT
},
isFiltered() {
return this.filters.length !== 0
},
conversationsInitialised() {
return this.$store.getters.conversationsInitialised
},
},
watch: {
token(value) {
if (value && this.isFiltered) {
this.isNavigating = true
}
},
showThreadsList(value) {
if (value) {
// Refresh a list
// FIXME requests should be paginated with offset
this.chatExtrasStore.fetchSubscribedThreadsList()
}
},
},
beforeMount() {
// Restore last fetched conversations from browser storage,
// before updated ones come from server
this.restoreConversations()
requestTabLeadership().then(() => {
this.isCurrentTabLeader = true
this.fetchConversations()
// Refreshes the conversations list every 30 seconds
this.refreshTimer = window.setInterval(() => {
this.fetchConversations()
}, 30000)
})
talkBroadcastChannel.addEventListener('message', (event) => {
if (this.isCurrentTabLeader) {
switch (event.data.message) {
case 'force-fetch-all-conversations':
if (event.data.options?.all) {
this.roomListModifiedBefore = 0
this.forceFullRoomListRefreshAfterXLoops = 10
}
this.debounceFetchConversations()
break
}
} else {
switch (event.data.message) {
case 'update-conversations':
this.$store.dispatch('patchConversations', {
conversations: event.data.conversations,
withRemoving: event.data.withRemoving,
})
this.federationStore.updatePendingSharesCount(event.data.invites)
break
case 'update-nextcloud-talk-hash':
this.talkHashStore.setNextcloudTalkHash(event.data.hash)
break
}
}
})
},
mounted() {
this.debounceFetchSearchResults = debounce(this.fetchSearchResults, 250)
this.debounceFetchConversations = debounce(this.fetchConversations, 3000)
this.debounceHandleScroll = debounce(this.handleScroll, 50)
EventBus.on('should-refresh-conversations', this.handleShouldRefreshConversations)
EventBus.once('conversations-received', this.handleConversationsReceived)
EventBus.on('route-change', this.onRouteChange)
EventBus.on('new-conversation-dialog:show', this.showModalNewConversation)
EventBus.on('open-conversations-list:show', this.showModalListConversations)
EventBus.on('call-phone-dialog:show', this.showModalCallPhoneDialog)
},
beforeUnmount() {
this.debounceFetchSearchResults.clear?.()
this.debounceFetchConversations.clear?.()
this.debounceHandleScroll.clear?.()
EventBus.off('should-refresh-conversations', this.handleShouldRefreshConversations)
EventBus.off('conversations-received', this.handleConversationsReceived)
EventBus.off('route-change', this.onRouteChange)
EventBus.off('new-conversation-dialog:show', this.showModalNewConversation)
EventBus.off('open-conversations-list:show', this.showModalListConversations)
EventBus.off('call-phone-dialog:show', this.showModalCallPhoneDialog)
this.cancelSearchPossibleConversations()
this.cancelSearchPossibleConversations = null
this.cancelSearchListedConversations()
this.cancelSearchListedConversations = null
if (this.refreshTimer) {
clearInterval(this.refreshTimer)
this.refreshTimer = null
}
},
methods: {
t,
showModalNewConversation() {
this.$refs.newConversationDialog.showModal()
},
showModalListConversations() {
this.$refs.openConversationsList.showModal()
},
showModalCallPhoneDialog() {
this.$refs.callPhoneDialog.showModal()
},
showInvitationHandler() {
this.$refs.invitationHandler.showModal()
},
handleFilter(filter) {
// Store the active filter
if (filter === null) {
this.filters = []
} else {
if (this.filters.includes(filter)) {
this.filters = this.filters.filter((f) => f !== filter)
} else {
// Hardcode 'unread' and 'mentions' to behave like radio buttons
if (filter === 'unread' || filter === 'mentions') {
this.filters = [...this.filters.filter((f) => f !== 'unread' && f !== 'mentions'), filter]
} else {
this.filters = [...this.filters, filter]
}
}
}
if (this.filters.length) {
BrowserStorage.setItem('filterEnabled', this.filters)
} else {
BrowserStorage.removeItem('filterEnabled')
}
// Clear the search input once a filter is active
this.searchText = ''
// Initiate the navigation status
this.isNavigating = false
},
scrollBottomUnread() {
this.preventFindingUnread = true
this.$refs.scroller.scrollToItem(this.lastUnreadMentionBelowViewportIndex)
setTimeout(() => {
this.handleUnreadMention()
this.preventFindingUnread = false
}, 500)
},
async fetchPossibleConversations() {
this.contactsLoading = true
try {
// FIXME: move to conversationsStore
this.cancelSearchPossibleConversations('canceled')
const { request, cancel } = CancelableRequest(autocompleteQuery)
this.cancelSearchPossibleConversations = cancel
const response = await request({
searchText: this.searchText,
token: 'new',
onlyUsers: !this.canStartConversations,
})
const oneToOneMap = this.conversationsList.reduce((acc, result) => {
if (result.type === CONVERSATION.TYPE.ONE_TO_ONE) {
acc.push(result.name)
}
return acc
}, [this.actorStore.userId])
this.searchResults = response?.data?.ocs?.data.filter((match) => {
return !(match.source === ATTENDEE.ACTOR_TYPE.USERS && oneToOneMap.includes(match.id))
}) ?? []
this.contactsLoading = false
} catch (exception) {
if (CancelableRequest.isCancel(exception)) {
return
}
console.error('Error searching for possible conversations', exception)
showError(t('spreed', 'An error occurred while performing the search'))
}
},
async fetchListedConversations() {
try {
this.listedConversationsLoading = true
// FIXME: move to conversationsStore
this.cancelSearchListedConversations('canceled')
const { request, cancel } = CancelableRequest(searchListedConversations)
this.cancelSearchListedConversations = cancel
const response = await request(this.searchText)
this.searchResultsListedConversations = response.data.ocs.data
this.listedConversationsLoading = false
} catch (exception) {
if (CancelableRequest.isCancel(exception)) {
return
}
console.error('Error searching for open conversations', exception)
showError(t('spreed', 'An error occurred while performing the search'))
}
},
async fetchSearchResults() {
if (!this.isSearching) {
return
}
this.resetNavigation()
await Promise.all([this.fetchPossibleConversations(), this.fetchListedConversations()])
this.initializeNavigation()
},
/**
* Create a new conversation with the selected user
* or bring up the dialog to create a new group/circle conversation
*
* @param {object} item The autocomplete suggestion to start a conversation with
* @param {string} item.id The ID of the target
* @param {string} item.label The displayname of the target
* @param {string} item.source The source of the target (e.g. users, groups, circle)
*/
async createAndJoinConversation(item) {
if (item.source === ATTENDEE.ACTOR_TYPE.USERS) {
// Create one-to-one conversation directly
const conversation = await this.$store.dispatch('createOneToOneConversation', item.id)
this.abortSearch()
this.$router.push({
name: 'conversation',
params: { token: conversation.token },
}).catch((err) => console.debug(`Error while pushing the new conversation's route: ${err}`))
} else {
// For other types, show the modal directly
this.$refs.newConversationDialog.showModalForItem(item)
}
},
switchToConversation(conversation) {
this.$store.dispatch('addConversation', conversation)
this.abortSearch()
this.$router.push({
name: 'conversation',
params: { token: conversation.token },
}).catch((err) => console.debug(`Error while pushing the new conversation's route: ${err}`))
},
async createConversation(roomName) {
try {
const response = await createLegacyConversation({
roomType: CONVERSATION.TYPE.GROUP,
roomName,
})
const conversation = response.data.ocs.data
this.switchToConversation(conversation)
} catch (error) {
console.error('Error creating new private conversation: ', error)
}
},
async restoreNoteToSelfConversation() {
const response = await fetchNoteToSelfConversation()
const conversation = response.data.ocs.data
this.switchToConversation(conversation)
},
// Reset the search text, therefore end the search operation.
abortSearch() {
this.searchText = ''
this.isFocused = false
if (this.cancelSearchPossibleConversations) {
this.cancelSearchPossibleConversations()
}
if (this.cancelSearchListedConversations) {
this.cancelSearchListedConversations()
}
},
showSettings() {
// FIXME: use local EventBus service instead of the global one
emit('show-settings')
},
/**
* @param {object} [options] Options for conversation refreshing
* @param {string} [options.token] The conversation token that got update
* @param {object} [options.properties] List of changed properties
* @param {boolean} [options.all] Whether all conversations should be fetched
*/
async handleShouldRefreshConversations(options) {
if (options?.token && options?.properties) {
await this.$store.dispatch('setConversationProperties', {
token: options.token,
properties: options.properties,
})
}
if (this.isCurrentTabLeader) {
if (options?.all === true) {
this.roomListModifiedBefore = 0
this.forceFullRoomListRefreshAfterXLoops = 10
}
this.debounceFetchConversations()
} else {
talkBroadcastChannel.postMessage({ message: 'force-fetch-all-conversations', options })
}
},
async fetchConversations() {
if (this.isFetchingConversations) {
return
}
this.isFetchingConversations = true
if (this.forceFullRoomListRefreshAfterXLoops === 0) {
this.roomListModifiedBefore = 0
this.forceFullRoomListRefreshAfterXLoops = 10
} else {
this.forceFullRoomListRefreshAfterXLoops--
}
/**
* Fetches the conversations from the server and then adds them one by one
* to the store.
*/
try {
const response = await this.$store.dispatch('fetchConversations', {
modifiedSince: this.roomListModifiedBefore,
})
// We can only support this with the HPB as otherwise rooms,
// you are not currently active in, will not be removed anymore,
// as there is no signaling message about it when the internal
// signaling is used.
if (loadState('spreed', 'signaling_mode') !== 'internal') {
if (response?.headers && response.headers['x-nextcloud-talk-modified-before']) {
this.roomListModifiedBefore = response.headers['x-nextcloud-talk-modified-before']
}
}
/**
* Emits a global event that is used in App.vue to update the page title once the
* ( if the current route is a conversation and once the conversations are received)
*/
EventBus.emit('conversations-received', {})
this.isFetchingConversations = false
} catch (error) {
console.debug('Error while fetching conversations: ', error)
this.isFetchingConversations = false
}
},
async restoreConversations() {
try {
if (await this.$store.dispatch('restoreConversations')) {
EventBus.emit('conversations-received', { fromBrowserStorage: true })
}
} catch (error) {
console.debug('Error while restoring conversations: ', error)
}
},
handleConversationsReceived() {
this.handleUnreadMention()
if (this.$route.params.token) {
this.showArchived = this.$store.getters.conversation(this.$route.params.token)?.isArchived ?? false
this.scrollToConversation(this.$route.params.token)
}
},
// Checks whether the conversations list is scrolled all the way to the top
// or not
handleScroll() {
this.handleUnreadMention()
},
/**
* Find position of the last unread conversation below viewport
*/
async handleUnreadMention() {
await this.$nextTick()
this.lastUnreadMentionBelowViewportIndex = null
const lastConversationInViewport = this.$refs.scroller.getLastItemInViewportIndex()
for (let i = this.filteredConversationsList.length - 1; i > lastConversationInViewport; i--) {
if (hasUnreadMentions(this.filteredConversationsList[i])) {
this.lastUnreadMentionBelowViewportIndex = i
return
}
}
},
async scrollToConversation(token) {
await this.$nextTick()
if (!this.$refs.scroller) {
return
}
this.$refs.scroller.scrollToConversation(token)
},
onRouteChange({ from, to }) {
if (from.name === 'conversation'
&& to.name === 'conversation'
&& from.params.token === to.params.token) {
// this is triggered when the hash in the URL changes
return
}
if (from.name === 'conversation') {
this.$store.dispatch('leaveConversation', { token: from.params.token })
}
if (to.name === 'conversation') {
this.abortSearch()
this.$store.dispatch('joinConversation', { token: to.params.token })
this.showArchived = this.$store.getters.conversation(to.params.token)?.isArchived ?? false
this.showThreadsList = false
this.scrollToConversation(to.params.token)
}
if (this.isMobile) {
emit('toggle-navigation', {
open: to.name === 'root' && from === START_LOCATION,
})
}
},
refreshTalkDashboard(event) {
// Throttle click and keyboard events
if (actualizeDataTimeout) {
return
}
actualizeDataTimeout = setTimeout(() => {
actualizeDataTimeout = null
}, 5_000)
if (this.$route.name === 'root') {
event.preventDefault()
EventBus.emit('refresh-talk-dashboard')
}
},
},
}
</script>
<style lang="scss" scoped>
.scroller {
height: 100%;
padding-inline: var(--default-grid-baseline);
overflow-y: auto;
line-height: 20px;
}
.new-conversation {
position: relative;
display: flex;
margin: calc(var(--default-grid-baseline) * 2);
align-items: center;
.filters {
position: absolute;
top: 0;
inset-inline-end: calc(var(--default-grid-baseline) + var(--default-clickable-area));
}
.actions {
position: absolute;
top: 0;
inset-inline-end: 0;
}
}
.navigation-item {
padding-inline: calc(var(--default-grid-baseline) * 2);
margin-block: var(--default-grid-baseline);
:deep(.app-navigation-entry-link) {
padding-inline-start: var(--default-grid-baseline);
}
:deep(.app-navigation-entry-icon) {
flex: 0 0 40px !important; // AVATAR.SIZE.DEFAULT
}
:deep(.app-navigation-entry__name) {
padding-inline-start: calc(2 * var(--default-grid-baseline));
font-weight: 500;
}
}
.unread-mention-button {
position: absolute !important;
/* stylelint-disable-next-line csstools/use-logical */
left: 50%;
transform: translateX(-50%);
z-index: 100;
bottom: 10px;
white-space: nowrap;
}
.conversations-search {
transition: all 0.15s ease;
z-index: 1;
// TODO replace with NcAppNavigationSearch
width: calc(100% - (var(--default-grid-baseline) + var(--default-clickable-area)) * 2);
display: flex;
&--expanded {
width: 100%;
}
:deep(.input-field) {
margin-block-start: 0;
}
}
.conversations__filters {
display: flex;
flex-wrap: wrap;
gap: var(--default-grid-baseline);
margin: var(--default-grid-baseline) calc(var(--default-grid-baseline) * 2);
}
.left-sidebar__settings-button-container {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
padding: calc(2 * var(--default-grid-baseline));
}
.left-sidebar__settings-button-bubble {
margin-inline: var(--default-grid-baseline);
color: var(--color-primary-element);
}
:deep(.empty-content) {
text-align: center;
padding: 20% 10px 0;
}
:deep(.app-navigation__list) {
padding: 0 !important;
}
</style>