Merge pull request #15496 from nextcloud/feat/9680/threads-frontend

feat(threads) add UI for threads feature
This commit is contained in:
Joas Schilling
2025-07-15 08:10:22 +02:00
committed by GitHub
17 changed files with 634 additions and 52 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

@ -139,6 +139,28 @@
{{ t('spreed', 'Download file') }}
</NcActionLink>
</template>
<NcActionSeparator />
<template v-if="supportThreads && !threadId">
<NcActionButton
v-if="message.isThread"
close-after-click
@click="threadId = message.threadId">
<template #icon>
<IconForumOutline :size="16" />
</template>
{{ t('spreed', 'Go to thread') }}
</NcActionButton>
<NcActionButton
v-else-if="canCreateThread"
close-after-click
@click="chatExtrasStore.createThread(message.token, message.id)">
<template #icon>
<IconForumOutline :size="16" />
</template>
{{ t('spreed', 'Create a thread') }}
</NcActionButton>
</template>
<NcActionButton
v-if="canForwardMessage && !isInNoteToSelf"
key="forward-to-note"
@ -318,6 +340,7 @@ import IconDownload from 'vue-material-design-icons/Download.vue'
import EmoticonOutline from 'vue-material-design-icons/EmoticonOutline.vue'
import EyeOffOutline from 'vue-material-design-icons/EyeOffOutline.vue'
import File from 'vue-material-design-icons/File.vue'
import IconForumOutline from 'vue-material-design-icons/ForumOutline.vue'
import Note from 'vue-material-design-icons/NoteEditOutline.vue'
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue'
import Pencil from 'vue-material-design-icons/Pencil.vue'
@ -325,11 +348,13 @@ import Plus from 'vue-material-design-icons/Plus.vue'
import Reply from 'vue-material-design-icons/Reply.vue'
import Share from 'vue-material-design-icons/Share.vue'
import Translate from 'vue-material-design-icons/Translate.vue'
import { useGetThreadId } from '../../../../../composables/useGetThreadId.ts'
import { useMessageInfo } from '../../../../../composables/useMessageInfo.ts'
import { ATTENDEE, CONVERSATION, MESSAGE, PARTICIPANT } from '../../../../../constants.ts'
import { hasTalkFeature } from '../../../../../services/CapabilitiesManager.ts'
import { getMessageReminder, removeMessageReminder, setMessageReminder } from '../../../../../services/remindersService.js'
import { useActorStore } from '../../../../../stores/actor.ts'
import { useChatExtrasStore } from '../../../../../stores/chatExtras.ts'
import { useIntegrationsStore } from '../../../../../stores/integrations.js'
import { useReactionsStore } from '../../../../../stores/reactions.js'
import { generatePublicShareDownloadUrl, generateUserFileUrl } from '../../../../../utils/davUtils.ts'
@ -354,6 +379,7 @@ export default {
AlarmIcon,
IconArrowLeft,
IconBellOff,
IconForumOutline,
CalendarClock,
CloseCircleOutline,
Check,
@ -435,6 +461,9 @@ export default {
const reactionsStore = useReactionsStore()
const { messageActions } = useIntegrationsStore()
const actorStore = useActorStore()
const chatExtrasStore = useChatExtrasStore()
const threadId = useGetThreadId()
const {
isEditable,
isDeleteable,
@ -446,10 +475,12 @@ export default {
isConversationModifiable,
} = useMessageInfo(message)
const supportReminders = hasTalkFeature(message.value.token, 'remind-me-later')
const supportThreads = hasTalkFeature(message.value.token, 'threads')
return {
messageActions,
supportReminders,
supportThreads,
reactionsStore,
isEditable,
isCurrentUserOwnMessage,
@ -460,6 +491,8 @@ export default {
isConversationReadOnly,
isConversationModifiable,
actorStore,
chatExtrasStore,
threadId,
}
},
@ -621,6 +654,11 @@ export default {
canReply() {
return this.message.isReplyable && !this.isConversationReadOnly && (this.conversation.permissions & PARTICIPANT.PERMISSIONS.CHAT) !== 0
},
canCreateThread() {
// FIXME This is the same thing for now
return this.canReply
},
},
watch: {

View File

@ -43,7 +43,7 @@
@mouseover="handleMarkdownMouseOver"
@mouseleave="handleMarkdownMouseLeave">
<!-- Replied parent message -->
<Quote v-if="message.parent" :message="message.parent" />
<Quote v-if="showQuote" :message="message.parent" />
<!-- Message content / text -->
<NcRichText :text="renderedMessage"
@ -146,6 +146,7 @@ import Quote from '../../../../Quote.vue'
import CallButton from '../../../../TopBar/CallButton.vue'
import ConversationActionsShortcut from '../../../../UIShared/ConversationActionsShortcut.vue'
import Poll from './Poll.vue'
import { useGetThreadId } from '../../../../../composables/useGetThreadId.ts'
import { useIsInCall } from '../../../../../composables/useIsInCall.js'
import { useMessageInfo } from '../../../../../composables/useMessageInfo.ts'
import { CONVERSATION, MESSAGE } from '../../../../../constants.ts'
@ -218,11 +219,13 @@ export default {
isEditable,
isFileShare,
} = useMessageInfo(message)
const threadId = useGetThreadId()
const isSidebar = inject('chatView:isSidebar', false)
return {
isInCall: useIsInCall(),
pollsStore: usePollsStore(),
threadId,
isEditable,
isFileShare,
isSidebar,
@ -241,6 +244,10 @@ export default {
},
computed: {
showQuote() {
return !!this.message.parent && this.message.parent.id !== this.threadId
},
renderedMessage() {
if (this.isFileShare && this.message.message !== '{file}') {
// Add a new line after file to split content into different paragraphs

View File

@ -15,6 +15,16 @@
<TransitionWrapper name="fade">
<div class="scroller__loading">
<NcLoadingIcon v-if="displayMessagesLoader" class="scroller__loading-element" :size="32" />
<!-- FIXME return from threaded view during the call -->
<NcButton
v-else-if="threadId && isInCall"
:title="t('spreed', 'Back')"
:aria-label="t('spreed', 'Back')"
@click="threadId = 0">
<template #icon>
<IconArrowLeft :size="20" />
</template>
</NcButton>
</div>
</TransitionWrapper>
@ -63,14 +73,17 @@ import moment from '@nextcloud/moment'
import debounce from 'debounce'
import uniqueId from 'lodash/uniqueId.js'
import { computed } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconArrowLeft from 'vue-material-design-icons/ArrowLeft.vue'
import Message from 'vue-material-design-icons/Message.vue'
import LoadingPlaceholder from '../UIShared/LoadingPlaceholder.vue'
import TransitionWrapper from '../UIShared/TransitionWrapper.vue'
import MessagesGroup from './MessagesGroup/MessagesGroup.vue'
import MessagesSystemGroup from './MessagesGroup/MessagesSystemGroup.vue'
import { useDocumentVisibility } from '../../composables/useDocumentVisibility.ts'
import { useGetThreadId } from '../../composables/useGetThreadId.ts'
import { useIsInCall } from '../../composables/useIsInCall.js'
import { ATTENDEE, CHAT, CONVERSATION, MESSAGE } from '../../constants.ts'
import { EventBus } from '../../services/EventBus.ts'
@ -83,8 +96,10 @@ const SCROLL_TOLERANCE = 10
export default {
name: 'MessagesList',
components: {
IconArrowLeft,
LoadingPlaceholder,
Message,
NcButton,
NcEmptyContent,
NcLoadingIcon,
TransitionWrapper,
@ -121,10 +136,13 @@ export default {
setup(props) {
const isDocumentVisible = useDocumentVisibility()
const isChatVisible = computed(() => isDocumentVisible.value && props.isVisible)
const threadId = useGetThreadId()
return {
isInCall: useIsInCall(),
chatExtrasStore: useChatExtrasStore(),
isChatVisible,
threadId,
}
},
@ -188,7 +206,14 @@ export default {
* @return {Array}
*/
messagesList() {
if (!this.threadId) {
return this.$store.getters.messagesList(this.token)
}
return this.$store.getters.messagesList(this.token)
.filter((message) => {
return message.threadId === this.threadId
})
},
isMessagesListPopulated() {
@ -664,7 +689,13 @@ export default {
if (!this.$store.getters.getFirstKnownMessageId(token)) {
try {
// Start from message hash or unread marker
const startingMessageId = focusMessageId !== null ? focusMessageId : this.conversation.lastReadMessage
let startingMessageId = focusMessageId !== null ? focusMessageId : this.conversation.lastReadMessage
// Check if thread is initially opened
if (this.threadId) {
// FIXME temporary get thread messages from the start
startingMessageId = this.threadId
}
// First time load, initialize important properties
if (!startingMessageId) {
throw new Error(`[DEBUG] spreed: context message ID is ${startingMessageId}`)
@ -1210,10 +1241,9 @@ export default {
async onRouteChange({ from, to }) {
if (from.name === 'conversation' && to.name === 'conversation'
&& from.params.token === to.params.token
&& from.hash !== to.hash) {
// the hash changed, need to focus/highlight another message
if (to.hash && to.hash.startsWith('#message_')) {
&& from.params.token === to.params.token) {
if (to.hash && to.hash.startsWith('#message_') && from.hash !== to.hash) {
// the hash changed, need to focus/highlight another message
const focusedId = this.getMessageIdFromHash(to.hash)
if (this.messagesList.find((m) => m.id === focusedId)) {
// need some delay (next tick is too short) to be able to run
@ -1236,6 +1266,21 @@ export default {
await this.getMessageContext(this.token, focusedId)
this.focusMessage(focusedId, true)
}
} else if (to.query.threadId && from.query.threadId !== to.query.threadId) {
// FIXME temporary get thread messages from the start
const topMostThreadMessage = this.$store.getters.messagesList(to.params.token).find((message) => message.id === +to.query.threadId)
if (!topMostThreadMessage) {
// Update environment around context to fill the gaps
this.$store.dispatch('setFirstKnownMessageId', {
token: this.token,
id: +to.query.threadId,
})
this.$store.dispatch('setLastKnownMessageId', {
token: this.token,
id: +to.query.threadId,
})
await this.getMessageContext(this.token, +to.query.threadId)
}
}
}
},

View File

@ -15,6 +15,7 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconClose from 'vue-material-design-icons/Close.vue'
import IconPencilOutline from 'vue-material-design-icons/PencilOutline.vue'
import AvatarWrapper from './AvatarWrapper/AvatarWrapper.vue'
import { useGetThreadId } from '../composables/useGetThreadId.ts'
import { useMessageInfo } from '../composables/useMessageInfo.ts'
import { AVATAR } from '../constants.ts'
import { EventBus } from '../services/EventBus.ts'
@ -36,6 +37,7 @@ const { message, canCancel = false, editMessage = false } = defineProps<{
const route = useRoute()
const actorStore = useActorStore()
const chatExtrasStore = useChatExtrasStore()
const threadId = useGetThreadId()
const {
isFileShare,
@ -48,9 +50,12 @@ const {
const actorInfo = computed(() => [actorDisplayNameWithFallback.value, remoteServer.value].filter((value) => value).join(' '))
const query = computed(() => ({ threadId: (isExistingMessage(message) && threadId.value === message.threadId) ? message.threadId : undefined }))
const hash = computed(() => '#message_' + message.id)
const component = computed(() => canCancel ? { tag: 'div', link: undefined } : { tag: 'router-link', link: { hash: hash.value } })
const component = computed(() => canCancel
? { tag: 'div', link: undefined }
: { tag: 'router-link', link: { query: query.value, hash: hash.value } })
const isOwnMessageQuoted = computed(() => isExistingMessage(message) ? actorStore.checkIfSelfIsActor(message) : false)

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

@ -10,17 +10,67 @@
'top-bar--in-call': isInCall,
'top-bar--authorised': getUserId,
}">
<ConversationIcon :key="conversation.token"
class="conversation-icon"
:offline="isOffline"
:item="conversation"
:size="AVATAR.SIZE.DEFAULT"
:disable-menu="false"
show-user-online-status
:hide-favorite="false"
:hide-call="false" />
<a
class="top-bar__icon-wrapper"
:class="{ 'top-bar__icon-wrapper--thread': !isInCall && currentThread }"
role="button"
:tabindex="0"
:title="t('spreed', 'Back')"
:aria-label="t('spreed', 'Back')"
@click="currentThread ? threadId = 0 : openConversationSettings()">
<IconArrowLeft
v-show="currentThread"
class="top-bar__icon-back bidirectional-icon"
:size="20" />
<ConversationIcon :key="conversation.token"
:offline="isOffline"
:item="conversation"
:size="AVATAR.SIZE.DEFAULT"
:disable-menu="false"
show-user-online-status
:hide-favorite="false"
:hide-call="false" />
</a>
<div class="top-bar__wrapper" :data-theme-dark="isInCall ? true : undefined">
<div
v-if="!isInCall && currentThread"
class="top-bar__wrapper">
<IconChevronRight class="bidirectional-icon" :size="20" />
<span class="conversation-header">
<AvatarWrapper
:id="currentThread.first.actorId"
:token="token"
:name="currentThread.first.actorDisplayName"
:source="currentThread.first.actorType"
:size="AVATAR.SIZE.DEFAULT"
disable-menu
disable-tooltip />
<div class="conversation-header__text">
<p class="title">
{{ currentThread.first.actorDisplayName.trim().split(' ')[0] + ': ' + currentThread.first.message }}
</p>
<p class="description">
{{ n('spreed', '%n reply', '%n replies', currentThread.thread.numReplies) }}
</p>
</div>
</span>
<NcButton
:aria-label="threadNotificationLabel"
:title="threadNotificationLabel"
:primary="!!threadNotification"
@click="() => {}">
<template #icon>
<IconBellOffOutline v-if="threadNotification" :size="20" />
<IconBellOutline v-else :size="20" />
</template>
</NcButton>
</div>
<div v-else
class="top-bar__wrapper"
:data-theme-dark="isInCall ? true : undefined">
<!-- conversation header -->
<a role="button"
class="conversation-header"
@ -71,8 +121,8 @@
variant="tertiary"
@click="openSidebar('participants')">
<template #icon>
<IconAccountMultiplePlus v-if="canExtendOneToOneConversation" :size="20" />
<IconAccountMultiple v-else :size="20" />
<IconAccountMultiplePlusOutline v-if="canExtendOneToOneConversation" :size="20" />
<IconAccountMultipleOutline v-else :size="20" />
</template>
<template v-if="!canExtendOneToOneConversation" #default>
{{ participantsInCall }}
@ -117,8 +167,13 @@ import { n, t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcPopover from '@nextcloud/vue/components/NcPopover'
import NcRichText from '@nextcloud/vue/components/NcRichText'
import IconAccountMultiple from 'vue-material-design-icons/AccountMultiple.vue'
import IconAccountMultiplePlus from 'vue-material-design-icons/AccountMultiplePlus.vue'
import IconAccountMultipleOutline from 'vue-material-design-icons/AccountMultipleOutline.vue'
import IconAccountMultiplePlusOutline from 'vue-material-design-icons/AccountMultiplePlusOutline.vue'
import IconArrowLeft from 'vue-material-design-icons/ArrowLeft.vue'
import IconBellOffOutline from 'vue-material-design-icons/BellOffOutline.vue'
import IconBellOutline from 'vue-material-design-icons/BellOutline.vue'
import IconChevronRight from 'vue-material-design-icons/ChevronRight.vue'
import AvatarWrapper from '../AvatarWrapper/AvatarWrapper.vue'
import BreakoutRoomsEditor from '../BreakoutRoomsEditor/BreakoutRoomsEditor.vue'
import CalendarEventsDialog from '../CalendarEventsDialog.vue'
import ConversationIcon from '../ConversationIcon.vue'
@ -129,10 +184,12 @@ import ReactionMenu from './ReactionMenu.vue'
import TasksCounter from './TasksCounter.vue'
import TopBarMediaControls from './TopBarMediaControls.vue'
import TopBarMenu from './TopBarMenu.vue'
import { useGetThreadId } from '../../composables/useGetThreadId.ts'
import { useGetToken } from '../../composables/useGetToken.ts'
import { AVATAR, CONVERSATION } from '../../constants.ts'
import { getTalkConfig, hasTalkFeature } from '../../services/CapabilitiesManager.ts'
import { useActorStore } from '../../stores/actor.ts'
import { useChatExtrasStore } from '../../stores/chatExtras.ts'
import { useGroupwareStore } from '../../stores/groupware.ts'
import { useSidebarStore } from '../../stores/sidebar.ts'
import { getStatusMessage } from '../../utils/userStatus.ts'
@ -146,6 +203,7 @@ export default {
components: {
// Components
AvatarWrapper,
BreakoutRoomsEditor,
CalendarEventsDialog,
CallButton,
@ -160,8 +218,12 @@ export default {
TasksCounter,
ReactionMenu,
// Icons
IconAccountMultiple,
IconAccountMultiplePlus,
IconAccountMultipleOutline,
IconAccountMultiplePlusOutline,
IconArrowLeft,
IconBellOffOutline,
IconBellOutline,
IconChevronRight,
},
props: {
@ -187,7 +249,9 @@ export default {
groupwareStore: useGroupwareStore(),
sidebarStore: useSidebarStore(),
actorStore: useActorStore(),
chatExtrasStore: useChatExtrasStore(),
CONVERSATION,
threadId: useGetThreadId(),
token: useGetToken(),
}
},
@ -200,6 +264,27 @@ export default {
},
computed: {
currentThread() {
if (!this.threadId) {
return null
}
return this.chatExtrasStore.getThread(this.token, this.threadId)
},
threadNotification() {
if (this.currentThread) {
return this.currentThread.attendee.notificationLevel
}
return null
},
threadNotificationLabel() {
if (this.currentThread?.attendee.notificationLevel) {
return t('spreed', 'Unsubscribe from thread')
}
return t('spreed', 'Subscribe to thread')
},
isOneToOneConversation() {
return this.conversation.type === CONVERSATION.TYPE.ONE_TO_ONE
|| this.conversation.type === CONVERSATION.TYPE.ONE_TO_ONE_FORMER
@ -283,6 +368,15 @@ export default {
this.groupwareStore.getUpcomingEvents(value)
},
},
currentThread: {
immediate: true,
handler(value) {
if (this.threadId && value === undefined) {
this.chatExtrasStore.fetchSingleThread(this.token, this.threadId)
}
},
},
},
mounted() {
@ -342,13 +436,13 @@ export default {
&--sidebar {
padding: calc(2 * var(--default-grid-baseline));
.conversation-icon {
.top-bar__icon-wrapper {
margin-inline-start: 0;
}
}
&--authorised:not(.top-bar--sidebar) {
.conversation-icon {
.top-bar__icon-wrapper {
margin-inline-start: calc(var(--default-clickable-area) + var(--default-grid-baseline));
}
}
@ -357,12 +451,45 @@ export default {
.top-bar__wrapper {
flex: 1 0;
display: flex;
flex-wrap: wrap;
gap: 3px;
align-items: center;
justify-content: flex-end;
}
.thread-header {
display: flex;
align-items: center;
width: 100%;
gap: var(--default-grid-baseline);
}
.top-bar__icon-wrapper {
position: relative;
background-color: var(--color-background-dark);
border-radius: var(--border-radius-pill);
transition-property: width, padding;
transition-duration: var(--animation-quick);
&:hover,
&:focus,
&:focus-visible {
background-color: var(--color-background-darker);
}
&--thread {
width: calc(var(--default-clickable-area) + 40px); // AVATAR.SIZE.DEFAULT
padding-inline-start: var(--default-clickable-area);
}
.top-bar__icon-back {
position: absolute;
width: var(--default-clickable-area);
height: 100%;
top: 0;
inset-inline-start: 0;
}
}
.conversation-header {
position: relative;
display: flex;

View File

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createSharedComposable } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
/**
* Shared composable to get threadId of current thread in conversation
*/
export const useGetThreadId = createSharedComposable(function() {
const router = useRouter()
const route = useRoute()
if (router) {
return computed<number>({
get: () => route.query.threadId ? Number(route.query.threadId) : 0,
set: (value: number) => {
router.push({ query: { ...route.query, threadId: value !== 0 ? value : undefined } })
},
})
} else {
return ref(0)
}
})

View File

@ -10,6 +10,7 @@ import { useStore } from 'vuex'
import { useActorStore } from '../stores/actor.ts'
import { useChatExtrasStore } from '../stores/chatExtras.ts'
import { prepareTemporaryMessage } from '../utils/prepareTemporaryMessage.ts'
import { useGetThreadId } from './useGetThreadId.ts'
/**
* Composable to generate temporary messages using defined in store information
@ -19,19 +20,25 @@ export function useTemporaryMessage(context: Store<unknown>) {
const store = context ?? useStore()
const chatExtrasStore = useChatExtrasStore()
const actorStore = useActorStore()
const threadId = useGetThreadId()
/**
* @param payload payload for generating a temporary message
*/
function createTemporaryMessage(payload: PrepareTemporaryMessagePayload) {
const parentId = chatExtrasStore.getParentIdToReply(payload.token)
const parent = parentId
? store.getters.message(payload.token, parentId)
: (threadId.value ? store.getters.message(payload.token, threadId.value) : undefined)
return prepareTemporaryMessage({
...payload,
actorId: actorStore.actorId ?? '',
actorType: actorStore.actorType ?? '',
actorDisplayName: actorStore.displayName,
parent: parentId && store.getters.message(payload.token, parentId),
parent,
threadId: threadId.value ? threadId.value : undefined,
isThread: threadId.value ? true : undefined,
})
}

View File

@ -549,6 +549,7 @@ const actions = {
|| message.systemMessage === 'reaction'
|| message.systemMessage === 'reaction_deleted'
|| message.systemMessage === 'reaction_revoked'
|| message.systemMessage === 'thread_created'
|| message.systemMessage === 'message_edited') {
if (!message.parent) {
return
@ -581,6 +582,15 @@ const actions = {
})
}
if (message.systemMessage === 'thread_created') {
// Check existing messages for having a threadId flag, and update them
context.getters.messagesList(token)
.filter((storedMessage) => storedMessage.threadId === message.threadId)
.forEach((storedMessage) => {
context.commit('addMessage', { token, message: Object.assign({}, storedMessage, { isThread: true }) })
})
}
// Quit processing
return
}

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]
@ -124,24 +132,20 @@ export const useChatExtrasStore = defineStore('chatExtras', {
},
/**
* Make a thread from a reply chain in given conversation
* Create a thread from a reply chain in given conversation
* If thread already exists, subscribe to it
*
* @param token - conversation token
* @param threadId - thread id to fetch
* @param messageId - message id of any reply in the chain
*/
async createThread(token: string, threadId: number) {
async createThread(token: string, messageId: number) {
try {
if (!this.threads[token]) {
this.threads[token] = {}
}
if (this.threads[token][threadId]) {
// Thread already exists, no need to create it again
return
}
const response = await createThreadForConversation(token, threadId)
this.threads[token][threadId] = response.data.ocs.data
const response = await createThreadForConversation(token, messageId)
this.threads[token][response.data.ocs.data.thread.id] = response.data.ocs.data
} catch (error) {
console.error('Error creating thread:', error)
}

View File

@ -41,6 +41,8 @@ describe('prepareTemporaryMessage', () => {
timestamp: 0,
token: TOKEN,
silent: false,
threadId: undefined,
isThread: undefined,
}
const parent = {
@ -111,15 +113,26 @@ describe('prepareTemporaryMessage', () => {
},
},
}
const threadPayload = {
...defaultPayload,
threadId: 123,
isThread: true,
}
const threadResult = {
...defaultResult,
threadId: 123,
isThread: true,
}
const tests = [
[defaultPayload, defaultResult],
[{ ...defaultPayload, parent }, { ...defaultResult, parent }],
[textFilePayload, textFileResult],
[audioFilePayload, audioFileResult],
[threadPayload, threadResult],
]
it.only.each(tests)('test case %# to match expected result', (payload, result) => {
it.each(tests)('test case %# to match expected result', (payload, result) => {
const temporaryMessage = prepareTemporaryMessage(payload)
expect(temporaryMessage).toStrictEqual(result)
})

View File

@ -17,6 +17,8 @@ export type PrepareTemporaryMessagePayload = Pick<ChatMessage,
| 'actorType'
| 'actorDisplayName'
| 'silent'
| 'threadId'
| 'isThread'
> & {
uploadId: string
index: number
@ -56,6 +58,8 @@ export function prepareTemporaryMessage({
actorDisplayName,
parent,
silent = false,
threadId,
isThread,
}: PrepareTemporaryMessagePayload): ChatMessage {
const date = new Date()
let tempId = 'temp-' + date.getTime()
@ -95,5 +99,7 @@ export function prepareTemporaryMessage({
actorType,
actorDisplayName,
silent,
threadId: threadId || undefined,
isThread,
}
}