diff --git a/src/__mocks__/capabilities.ts b/src/__mocks__/capabilities.ts index 43c9da15a1..8c5d6dd725 100644 --- a/src/__mocks__/capabilities.ts +++ b/src/__mocks__/capabilities.ts @@ -100,6 +100,7 @@ export const mockedCapabilities: Capabilities = { 'mutual-calendar-events', 'upcoming-reminders', 'sensitive-conversations', + 'threads', // Conditional features 'message-expiration', 'reactions', diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue index 0267878e4a..53fd805f29 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue @@ -139,6 +139,28 @@ {{ t('spreed', 'Download file') }} + + - +
+ + + +
@@ -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) + } } } }, diff --git a/src/components/Quote.vue b/src/components/Quote.vue index 685e095c31..9ffe35adfe 100644 --- a/src/components/Quote.vue +++ b/src/components/Quote.vue @@ -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) diff --git a/src/components/RightSidebar/RightSidebar.vue b/src/components/RightSidebar/RightSidebar.vue index 39442f2377..64aacf2e39 100644 --- a/src/components/RightSidebar/RightSidebar.vue +++ b/src/components/RightSidebar/RightSidebar.vue @@ -25,21 +25,28 @@ + @update:state="handleUpdateState" /> - + @close="handleUpdateState('default')" /> + + + - + @@ -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' } diff --git a/src/components/RightSidebar/RightSidebarContent.vue b/src/components/RightSidebar/RightSidebarContent.vue index f81d51dd92..7fb311079c 100644 --- a/src/components/RightSidebar/RightSidebarContent.vue +++ b/src/components/RightSidebar/RightSidebarContent.vue @@ -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() { + @click="emit('update:state', 'search')"> @@ -262,12 +269,12 @@ function handleHeaderClick() { -