feat(threads): show threads overview in RightSidebar

Signed-off-by: Maksim Sukharev <antreesy.web@gmail.com>
This commit is contained in:
Maksim Sukharev
2025-07-11 13:36:12 +02:00
parent b0c2c62167
commit 63b0782823
7 changed files with 315 additions and 14 deletions

View File

@ -100,6 +100,7 @@ export const mockedCapabilities: Capabilities = {
'mutual-calendar-events',
'upcoming-reminders',
'sensitive-conversations',
'threads',
// Conditional features
'message-expiration',
'reactions',

View File

@ -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'
}

View File

@ -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>

View File

@ -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>

View 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>

View 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>

View File

@ -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]