mirror of
https://github.com/nextcloud/spreed.git
synced 2025-08-16 15:27:59 +00:00
Merge pull request #15496 from nextcloud/feat/9680/threads-frontend
feat(threads) add UI for threads feature
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',
|
||||
|
@ -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: {
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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>
|
@ -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;
|
||||
|
27
src/composables/useGetThreadId.ts
Normal file
27
src/composables/useGetThreadId.ts
Normal 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)
|
||||
}
|
||||
})
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user