mirror of
https://github.com/nextcloud/spreed.git
synced 2025-07-21 10:37:10 +00:00
feat(threads): show threads overview in RightSidebar
Signed-off-by: Maksim Sukharev <antreesy.web@gmail.com>
This commit is contained in:
@ -100,6 +100,7 @@ export const mockedCapabilities: Capabilities = {
|
||||
'mutual-calendar-events',
|
||||
'upcoming-reminders',
|
||||
'sensitive-conversations',
|
||||
'threads',
|
||||
// Conditional features
|
||||
'message-expiration',
|
||||
'reactions',
|
||||
|
@ -25,21 +25,28 @@
|
||||
<RightSidebarContent ref="sidebarContent"
|
||||
:is-user="!!getUserId"
|
||||
:mode="CONTENT_MODES[contentModeIndex]"
|
||||
:state="showSearchMessagesTab ? 'search' : 'default'"
|
||||
:state="contentState"
|
||||
@update:mode="handleUpdateMode"
|
||||
@update:search="handleShowSearch" />
|
||||
@update:state="handleUpdateState" />
|
||||
</template>
|
||||
<template #description>
|
||||
<InternalSignalingHint />
|
||||
<LobbyStatus v-if="canFullModerate && hasLobbyEnabled" :token="token" />
|
||||
</template>
|
||||
<NcAppSidebarTab v-if="showSearchMessagesTab"
|
||||
<NcAppSidebarTab v-if="contentState === 'search'"
|
||||
id="search-messages"
|
||||
key="search-messages"
|
||||
:order="0"
|
||||
:name="t('spreed', 'Search messages')">
|
||||
<SearchMessagesTab :is-active="activeTab === 'search-messages'"
|
||||
@close="handleShowSearch(false)" />
|
||||
@close="handleUpdateState('default')" />
|
||||
</NcAppSidebarTab>
|
||||
<NcAppSidebarTab v-else-if="contentState === 'threads'"
|
||||
id="threads"
|
||||
key="threads"
|
||||
:order="0"
|
||||
:name="t('spreed', 'Threads')">
|
||||
<ThreadsTab @close="handleUpdateState('default')" />
|
||||
</NcAppSidebarTab>
|
||||
<template v-else>
|
||||
<NcAppSidebarTab v-if="isInCall"
|
||||
@ -108,7 +115,7 @@
|
||||
<template #icon>
|
||||
<IconFolderMultipleImage :size="20" />
|
||||
</template>
|
||||
<SharedItemsTab :active="activeTab === 'shared-items'" />
|
||||
<SharedItemsTab :active="activeTab === 'shared-items'" @update:state="handleUpdateState" />
|
||||
</NcAppSidebarTab>
|
||||
</template>
|
||||
</NcAppSidebar>
|
||||
@ -140,6 +147,7 @@ import RightSidebarContent from './RightSidebarContent.vue'
|
||||
import SearchMessagesTab from './SearchMessages/SearchMessagesTab.vue'
|
||||
import SharedItemsTab from './SharedItems/SharedItemsTab.vue'
|
||||
import SipSettings from './SipSettings.vue'
|
||||
import ThreadsTab from './Threads/ThreadsTab.vue'
|
||||
import { useGetParticipants } from '../../composables/useGetParticipants.ts'
|
||||
import { useGetToken } from '../../composables/useGetToken.ts'
|
||||
import { CONVERSATION, PARTICIPANT, WEBINAR } from '../../constants.ts'
|
||||
@ -167,6 +175,7 @@ export default {
|
||||
SearchMessagesTab,
|
||||
SetGuestUsername,
|
||||
SharedItemsTab,
|
||||
ThreadsTab,
|
||||
SipSettings,
|
||||
// Icons
|
||||
IconAccountMultiple,
|
||||
@ -250,7 +259,7 @@ export default {
|
||||
return {
|
||||
contactsLoading: false,
|
||||
unreadNotificationHandle: null,
|
||||
showSearchMessagesTab: false,
|
||||
contentState: 'default',
|
||||
}
|
||||
},
|
||||
|
||||
@ -427,6 +436,7 @@ export default {
|
||||
isInCall(newValue) {
|
||||
if (newValue) {
|
||||
// Set 'chat' tab as active, and switch to it if sidebar is open
|
||||
this.contentState = 'default'
|
||||
this.activeTab = 'chat'
|
||||
return
|
||||
}
|
||||
@ -506,11 +516,13 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
handleShowSearch(value) {
|
||||
this.showSearchMessagesTab = value
|
||||
handleUpdateState(value) {
|
||||
this.contentState = value
|
||||
// FIXME upstream: NcAppSidebar should emit update:active
|
||||
if (value) {
|
||||
if (value === 'search') {
|
||||
this.activeTab = 'search-messages'
|
||||
} else if (value === 'threads') {
|
||||
this.activeTab = 'threads'
|
||||
} else {
|
||||
this.activeTab = this.isInCall ? 'chat' : 'participants'
|
||||
}
|
||||
|
@ -43,14 +43,16 @@ type MutualEvent = {
|
||||
color: string
|
||||
}
|
||||
|
||||
type SidebarContentState = 'default' | 'search' | 'threads'
|
||||
|
||||
const props = defineProps<{
|
||||
isUser: boolean
|
||||
state: 'default' | 'search'
|
||||
state: SidebarContentState
|
||||
mode: 'compact' | 'preview' | 'full'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:search', value: boolean): void
|
||||
(event: 'update:state', value: SidebarContentState): void
|
||||
(event: 'update:mode', value: 'compact' | 'preview' | 'full'): void
|
||||
}>()
|
||||
|
||||
@ -83,6 +85,11 @@ const sidebarTitle = computed(() => {
|
||||
escape: false,
|
||||
sanitize: false,
|
||||
})
|
||||
} else if (props.state === 'threads') {
|
||||
return t('spreed', 'Threads in {name}', { name: conversation.value.displayName }, {
|
||||
escape: false,
|
||||
sanitize: false,
|
||||
})
|
||||
}
|
||||
return conversation.value.displayName
|
||||
})
|
||||
@ -202,7 +209,7 @@ function handleHeaderClick() {
|
||||
<NcButton variant="tertiary"
|
||||
:title="t('spreed', 'Search messages')"
|
||||
:aria-label="t('spreed', 'Search messages')"
|
||||
@click="emit('update:search', true)">
|
||||
@click="emit('update:state', 'search')">
|
||||
<template #icon>
|
||||
<IconMagnify :size="20" />
|
||||
</template>
|
||||
@ -262,12 +269,12 @@ function handleHeaderClick() {
|
||||
</template>
|
||||
|
||||
<!-- Search messages in this conversation -->
|
||||
<template v-else-if="isUser && state === 'search'">
|
||||
<template v-else-if="isUser">
|
||||
<div class="content__header content__header--row">
|
||||
<NcButton variant="tertiary"
|
||||
:title="t('spreed', 'Back')"
|
||||
:aria-label="t('spreed', 'Back')"
|
||||
@click="emit('update:search', false)">
|
||||
@click="emit('update:state', 'default')">
|
||||
<template #icon>
|
||||
<IconArrowLeft class="bidirectional-icon" :size="20" />
|
||||
</template>
|
||||
|
@ -17,11 +17,14 @@ import NcAppNavigationCaption from '@nextcloud/vue/components/NcAppNavigationCap
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcCollectionList from '@nextcloud/vue/components/NcCollectionList'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcListItem from '@nextcloud/vue/components/NcListItem'
|
||||
import NcRelatedResourcesPanel from '@nextcloud/vue/components/NcRelatedResourcesPanel'
|
||||
import DotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue'
|
||||
import IconDotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue'
|
||||
import FolderMultipleImage from 'vue-material-design-icons/FolderMultipleImage.vue'
|
||||
import IconPoll from 'vue-material-design-icons/Poll.vue'
|
||||
import LoadingComponent from '../../LoadingComponent.vue'
|
||||
import ThreadItem from '../Threads/ThreadItem.vue'
|
||||
import SharedItems from './SharedItems.vue'
|
||||
import SharedItemsBrowser from './SharedItemsBrowser.vue'
|
||||
import { useGetToken } from '../../../composables/useGetToken.ts'
|
||||
@ -29,6 +32,7 @@ import { CONVERSATION } from '../../../constants.ts'
|
||||
import { hasTalkFeature } from '../../../services/CapabilitiesManager.ts'
|
||||
import { EventBus } from '../../../services/EventBus.ts'
|
||||
import { useActorStore } from '../../../stores/actor.ts'
|
||||
import { useChatExtrasStore } from '../../../stores/chatExtras.ts'
|
||||
import { useSharedItemsStore } from '../../../stores/sharedItems.ts'
|
||||
import { useSidebarStore } from '../../../stores/sidebar.ts'
|
||||
import {
|
||||
@ -41,6 +45,11 @@ import {
|
||||
const props = defineProps<{
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:state', value: 'threads'): void
|
||||
}>()
|
||||
|
||||
const token = useGetToken()
|
||||
const showSharedItemsBrowser = ref(false)
|
||||
const browserActiveTab = ref('')
|
||||
@ -48,6 +57,7 @@ const projectsEnabled = loadState('core', 'projects_enabled', false)
|
||||
const hasRelatedResources = ref(false)
|
||||
|
||||
const store = useStore()
|
||||
const chatExtrasStore = useChatExtrasStore()
|
||||
const sharedItemsStore = useSharedItemsStore()
|
||||
const sidebarStore = useSidebarStore()
|
||||
const actorStore = useActorStore()
|
||||
@ -60,9 +70,13 @@ const canCreatePollDrafts = computed(() => {
|
||||
const sharedItems = computed(() => sharedItemsStore.sharedItems(token.value))
|
||||
const hasSharedItems = computed(() => Object.keys(sharedItems.value).length > 0)
|
||||
|
||||
const supportThreads = computed(() => hasTalkFeature(token.value, 'threads'))
|
||||
const threadsInformation = computed(() => supportThreads.value ? chatExtrasStore.getThreadsList(token.value).slice(0, 3) : [])
|
||||
|
||||
watch([token, () => props.active, () => sidebarStore.show], ([token, isActive, isOpen]) => {
|
||||
if (token && isActive && isOpen) {
|
||||
sharedItemsStore.getSharedItemsOverview(token)
|
||||
supportThreads.value && chatExtrasStore.fetchRecentThreadsList(token)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
@ -110,6 +124,23 @@ function openPollDraftHandler() {
|
||||
</template>
|
||||
{{ t('spreed', 'Browse poll drafts') }}
|
||||
</NcButton>
|
||||
<!-- Threads overview -->
|
||||
<template v-if="supportThreads && threadsInformation.length">
|
||||
<NcAppNavigationCaption :name="t('spreed', 'Recent threads')" />
|
||||
<ul class="threads-list">
|
||||
<ThreadItem v-for="thread of threadsInformation"
|
||||
:key="`thread_${thread.thread.id}`"
|
||||
:thread="thread" />
|
||||
<NcListItem
|
||||
:name="t('spreed', 'Show more threads')"
|
||||
one-line
|
||||
@click="emit('update:state', 'threads')">
|
||||
<template #icon>
|
||||
<IconDotsHorizontal class="threads-icon" :size="20" />
|
||||
</template>
|
||||
</NcListItem>
|
||||
</ul>
|
||||
</template>
|
||||
<!-- Shared items grouped by type -->
|
||||
<template v-for="type in sharedItemsOrder" :key="type">
|
||||
<div v-if="sharedItems[type]">
|
||||
@ -195,4 +226,14 @@ function openPollDraftHandler() {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.threads {
|
||||
&-list {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
width: 40px; // AVATAR.SIZE.DEFAULT
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
175
src/components/RightSidebar/Threads/ThreadItem.vue
Normal file
175
src/components/RightSidebar/Threads/ThreadItem.vue
Normal file
@ -0,0 +1,175 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationAsRelative } from 'vue-router'
|
||||
import type {
|
||||
ChatMessage,
|
||||
ThreadInfo,
|
||||
} from '../../../types/index.ts'
|
||||
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import NcListItem from '@nextcloud/vue/components/NcListItem'
|
||||
import IconArrowLeftTop from 'vue-material-design-icons/ArrowLeftTop.vue'
|
||||
import IconBellOutline from 'vue-material-design-icons/BellOutline.vue'
|
||||
import AvatarWrapper from '../../AvatarWrapper/AvatarWrapper.vue'
|
||||
import { ATTENDEE } from '../../../constants.ts'
|
||||
|
||||
const { thread } = defineProps<{ thread: ThreadInfo }>()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useStore()
|
||||
|
||||
const threadAuthor = computed(() => thread.first.actorDisplayName.trim().split(' ')[0])
|
||||
const lastActivity = computed(() => thread.thread.lastActivity * 1000)
|
||||
const name = computed(() => getSimpleLine(thread.first))
|
||||
const subname = computed(() => {
|
||||
if (!thread.last) {
|
||||
return t('spreed', 'No messages')
|
||||
}
|
||||
const actor = thread.last.actorDisplayName.trim().split(' ')[0]
|
||||
|
||||
if (!actor && thread.last.actorType === ATTENDEE.ACTOR_TYPE.GUESTS) {
|
||||
return t('spreed', 'Guest')
|
||||
}
|
||||
|
||||
return t('spreed', '{actor}: {lastMessage}', { actor, lastMessage: getSimpleLine(thread.last) }, {
|
||||
escape: false,
|
||||
sanitize: false,
|
||||
})
|
||||
})
|
||||
|
||||
const to = computed<RouteLocationAsRelative>(() => {
|
||||
return {
|
||||
name: 'conversation',
|
||||
params: { token: thread.thread.roomToken },
|
||||
query: { threadId: thread.thread.id },
|
||||
}
|
||||
})
|
||||
|
||||
const active = computed(() => {
|
||||
return route.fullPath.startsWith(router.resolve(to.value).fullPath)
|
||||
})
|
||||
|
||||
const timeFormat = computed<Intl.DateTimeFormatOptions>(() => {
|
||||
if (new Date().toDateString() === new Date(lastActivity.value).toDateString()) {
|
||||
return { timeStyle: 'short' }
|
||||
}
|
||||
return { dateStyle: 'short' }
|
||||
})
|
||||
|
||||
/**
|
||||
* FIXME copied from conversation item/composable/quote component, should be shared from utils
|
||||
* @param message chat message object
|
||||
*/
|
||||
function getSimpleLine(message: ChatMessage | undefined) {
|
||||
if (!message) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let text = message.message
|
||||
|
||||
Object.entries(message.messageParameters as ChatMessage['messageParameters']).forEach(([key, value]) => {
|
||||
text = text.replaceAll('{' + key + '}', value.name)
|
||||
})
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcListItem :data-nav-id="`thread_${thread.thread.id}`"
|
||||
class="thread"
|
||||
:name="name"
|
||||
:to="to"
|
||||
:active="active"
|
||||
force-menu>
|
||||
<template #icon>
|
||||
<AvatarWrapper
|
||||
:id="thread.first.actorId"
|
||||
:name="thread.first.actorDisplayName"
|
||||
:source="thread.first.actorType"
|
||||
disable-menu
|
||||
:token="thread.thread.roomToken" />
|
||||
</template>
|
||||
<template #name>
|
||||
<span class="thread__author">{{ threadAuthor }}</span>
|
||||
<span>{{ name }}</span>
|
||||
</template>
|
||||
<template #subname>
|
||||
{{ subname }}
|
||||
</template>
|
||||
<template #actions>
|
||||
<NcActionButton close-after-click
|
||||
@click.stop="() => { console.log('Subscribe') }">
|
||||
<template #icon>
|
||||
<IconBellOutline :size="20" />
|
||||
</template>
|
||||
{{ t('spreed', 'Subscribe to thread') }}
|
||||
</NcActionButton>
|
||||
</template>
|
||||
<template #details>
|
||||
<span class="thread__details">
|
||||
<span class="thread__details-replies">
|
||||
<IconArrowLeftTop :size="16" />
|
||||
{{ thread.thread.numReplies }}
|
||||
</span>
|
||||
<NcDateTime
|
||||
:timestamp="lastActivity"
|
||||
:format="timeFormat"
|
||||
:relative-time="false"
|
||||
ignore-seconds />
|
||||
</span>
|
||||
</template>
|
||||
</NcListItem>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.thread {
|
||||
:deep(.list-item-content__name) {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
&__author {
|
||||
margin-inline-end: calc(0.5 * var(--default-grid-baseline));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.list-item-content__subname) {
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
&__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
font-size: var(--font-size-small);
|
||||
|
||||
&-replies {
|
||||
display: flex;
|
||||
gap: calc(0.5 * var(--default-grid-baseline));
|
||||
padding-inline: calc(2 * var(--default-grid-baseline));
|
||||
border-radius: var(--border-radius-pill);
|
||||
background-color: var(--color-primary-element-light);
|
||||
color: var(--color-main-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.list-item__wrapper--active .thread__details-replies {
|
||||
color: var(--color-primary-text);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
57
src/components/RightSidebar/Threads/ThreadsTab.vue
Normal file
57
src/components/RightSidebar/Threads/ThreadsTab.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocation } from 'vue-router'
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import ThreadItem from './ThreadItem.vue'
|
||||
import { useGetToken } from '../../../composables/useGetToken.ts'
|
||||
import { useIsInCall } from '../../../composables/useIsInCall.js'
|
||||
import { EventBus } from '../../../services/EventBus.ts'
|
||||
import { useChatExtrasStore } from '../../../stores/chatExtras.ts'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
}>()
|
||||
|
||||
const chatExtrasStore = useChatExtrasStore()
|
||||
const isInCall = useIsInCall()
|
||||
const token = useGetToken()
|
||||
const threadsInformation = computed(() => chatExtrasStore.getThreadsList(token.value))
|
||||
|
||||
onMounted(() => {
|
||||
EventBus.on('route-change', onRouteChange)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
EventBus.off('route-change', onRouteChange)
|
||||
})
|
||||
|
||||
const onRouteChange = ({ from, to }: { from: RouteLocation, to: RouteLocation }): void => {
|
||||
if (to.name !== 'conversation' || from.params.token !== to.params.token || (from.query.threadId !== to.query.threadId && isInCall.value)) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="threads-tab__list">
|
||||
<ThreadItem v-for="thread of threadsInformation"
|
||||
:key="`thread_${thread.thread.id}`"
|
||||
:thread="thread" />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.threads-tab {
|
||||
&__list {
|
||||
transition: all 0.15s ease;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -54,6 +54,14 @@ export const useChatExtrasStore = defineStore('chatExtras', {
|
||||
}
|
||||
},
|
||||
|
||||
getThreadsList: (state) => (token: string): ThreadInfo[] => {
|
||||
if (state.threads[token]) {
|
||||
return Object.values(state.threads[token]).sort((a, b) => b.thread.lastActivity - a.thread.lastActivity)
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
getParentIdToReply: (state) => (token: string) => {
|
||||
if (state.parentToReply[token]) {
|
||||
return state.parentToReply[token]
|
||||
|
Reference in New Issue
Block a user