diff options
-rw-r--r-- | src/components/LeftSidebar/ConversationsList/Conversation.spec.js | 6 | ||||
-rw-r--r-- | src/components/LeftSidebar/ConversationsList/Conversation.vue | 118 | ||||
-rw-r--r-- | src/components/LeftSidebar/ConversationsList/ConversationSearchResult.vue | 103 | ||||
-rw-r--r-- | src/components/LeftSidebar/ConversationsList/ConversationsListVirtual.vue (renamed from src/components/LeftSidebar/ConversationsListVirtual.vue) | 4 | ||||
-rw-r--r-- | src/components/LeftSidebar/ConversationsList/ConversationsSearchListVirtual.vue | 86 | ||||
-rw-r--r-- | src/components/LeftSidebar/LeftSidebar.vue | 2 | ||||
-rw-r--r-- | src/components/RoomSelector.spec.js | 211 | ||||
-rw-r--r-- | src/components/RoomSelector.vue | 248 | ||||
-rw-r--r-- | src/composables/useConversationInfo.js | 138 |
9 files changed, 606 insertions, 310 deletions
diff --git a/src/components/LeftSidebar/ConversationsList/Conversation.spec.js b/src/components/LeftSidebar/ConversationsList/Conversation.spec.js index 773f42c87..8167a34e3 100644 --- a/src/components/LeftSidebar/ConversationsList/Conversation.spec.js +++ b/src/components/LeftSidebar/ConversationsList/Conversation.spec.js @@ -45,13 +45,13 @@ describe('Conversation.vue', () => { testStoreConfig = cloneDeep(storeConfig) messagesMock = jest.fn().mockReturnValue({}) testStoreConfig.modules.messagesStore.getters.messages = () => messagesMock - testStoreConfig.modules.actorStore.getters.getUserId = () => jest.fn().mockReturnValue('user-id-self') store = new Vuex.Store(testStoreConfig) // common defaults item = { token: TOKEN, actorId: 'actor-id-1', + actorType: ATTENDEE.ACTOR_TYPE.USERS, participants: [ ], participantType: PARTICIPANT.TYPE.USER, @@ -155,7 +155,7 @@ describe('Conversation.vue', () => { }) test('displays own last chat message with "You" as author', () => { - item.lastMessage.actorId = 'user-id-self' + item.lastMessage.actorId = 'actor-id-1' testConversationLabel(item, 'You: hello') }) @@ -174,7 +174,7 @@ describe('Conversation.vue', () => { test('displays own last message with "You" author in one to one conversations', () => { item.type = CONVERSATION.TYPE.ONE_TO_ONE - item.lastMessage.actorId = 'user-id-self' + item.lastMessage.actorId = 'actor-id-1' testConversationLabel(item, 'You: hello') }) diff --git a/src/components/LeftSidebar/ConversationsList/Conversation.vue b/src/components/LeftSidebar/ConversationsList/Conversation.vue index 8a6b72aba..3752590dc 100644 --- a/src/components/LeftSidebar/ConversationsList/Conversation.vue +++ b/src/components/LeftSidebar/ConversationsList/Conversation.vue @@ -138,6 +138,7 @@ <script> +import { toRefs } from 'vue' import { Fragment } from 'vue-frag' import { isNavigationFailure, NavigationFailureType } from 'vue-router' @@ -162,7 +163,8 @@ import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js' import ConversationIcon from './../../ConversationIcon.vue' -import { CONVERSATION, PARTICIPANT, ATTENDEE } from '../../../constants.js' +import { useConversationInfo } from '../../../composables/useConversationInfo.js' +import { CONVERSATION, PARTICIPANT } from '../../../constants.js' import { copyConversationLinkToClipboard } from '../../../services/urlService.js' export default { @@ -215,6 +217,16 @@ export default { emits: ['click'], + setup(props) { + const { item, isSearchResult } = toRefs(props) + const { counterType, conversationInformation } = useConversationInfo({ item, isSearchResult }) + + return { + counterType, + conversationInformation, + } + }, + data() { return { isDialogOpen: false, @@ -226,18 +238,6 @@ export default { return this.$store.getters.getMainContainerSelector() }, - counterType() { - if (this.item.unreadMentionDirect || (this.item.unreadMessages !== 0 && ( - this.item.type === CONVERSATION.TYPE.ONE_TO_ONE || this.item.type === CONVERSATION.TYPE.ONE_TO_ONE_FORMER - ))) { - return 'highlighted' - } else if (this.item.unreadMention) { - return 'outlined' - } else { - return '' - } - }, - canFavorite() { return this.item.participantType !== PARTICIPANT.TYPE.USER_SELF_JOINED }, @@ -266,53 +266,6 @@ export default { return this.item.canLeaveConversation }, - conversationInformation() { - // temporary item while joining - if (!this.isSearchResult && !this.item.actorId) { - return t('spreed', 'Joining conversation …') - } - - if (!Object.keys(this.lastChatMessage).length) { - return '' - } - - if (this.shortLastChatMessageAuthor === '') { - return this.simpleLastChatMessage - } - - if (this.lastChatMessage.actorId === this.$store.getters.getUserId()) { - return t('spreed', 'You: {lastMessage}', { - lastMessage: this.simpleLastChatMessage, - }, undefined, { - escape: false, - sanitize: false, - }) - } - - if (this.item.type === CONVERSATION.TYPE.ONE_TO_ONE - || this.item.type === CONVERSATION.TYPE.ONE_TO_ONE_FORMER - || this.item.type === CONVERSATION.TYPE.CHANGELOG) { - return this.simpleLastChatMessage - } - - return t('spreed', '{actor}: {lastMessage}', { - actor: this.shortLastChatMessageAuthor, - lastMessage: this.simpleLastChatMessage, - }, undefined, { - escape: false, - sanitize: false, - }) - }, - - // Get the last message for this conversation from the message store instead - // of the conversations store. The message store is updated immediately, - // while the conversations store is refreshed every 30 seconds. This allows - // to display message previews in this component as soon as new messages are - // received by the server. - lastChatMessage() { - return this.item.lastMessage - }, - dialogMessage() { return t('spreed', 'Do you really want to delete "{displayName}"?', this.item, undefined, { escape: false, @@ -320,51 +273,6 @@ export default { }) }, - /** - * This is a simplified version of the last chat message. - * Parameters are parsed without markup (just replaced with the name), - * e.g. no avatars on mentions. - * - * @return {string} A simple message to show below the conversation name - */ - simpleLastChatMessage() { - if (!Object.keys(this.lastChatMessage).length) { - return '' - } - - const params = this.lastChatMessage.messageParameters - let subtitle = this.lastChatMessage.message.trim() - - // We don't really use rich objects in the subtitle, instead we fall back to the name of the item - Object.keys(params).forEach((parameterKey) => { - subtitle = subtitle.replace('{' + parameterKey + '}', params[parameterKey].name) - }) - - return subtitle - }, - - /** - * @return {string} Part of the name until the first space - */ - shortLastChatMessageAuthor() { - if (!Object.keys(this.lastChatMessage).length - || this.lastChatMessage.systemMessage.length) { - return '' - } - - let author = this.lastChatMessage.actorDisplayName.trim() - const spacePosition = author.indexOf(' ') - if (spacePosition !== -1) { - author = author.substring(0, spacePosition) - } - - if (author.length === 0 && this.lastChatMessage.actorType === ATTENDEE.ACTOR_TYPE.GUESTS) { - return t('spreed', 'Guest') - } - - return author - }, - to() { return this.item?.token ? { diff --git a/src/components/LeftSidebar/ConversationsList/ConversationSearchResult.vue b/src/components/LeftSidebar/ConversationsList/ConversationSearchResult.vue new file mode 100644 index 000000000..bec14b91f --- /dev/null +++ b/src/components/LeftSidebar/ConversationsList/ConversationSearchResult.vue @@ -0,0 +1,103 @@ +<!-- + - @copyright Copyright (c) 2024 Maksim Sukharev <antreesy.web@gmail.com> + - + - @author Joas Schilling <coding@schilljs.com> + - @author Maksim Sukharev <antreesy.web@gmail.com> + - + - @license AGPL-3.0-or-later + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. +--> + +<template> + <NcListItem :key="item.token" + :name="item.displayName" + :title="item.displayName" + :active="item.token === selectedRoom" + :bold="exposeMessages && !!item.unreadMessages" + :counter-number="exposeMessages ? item.unreadMessages : 0" + :counter-type="counterType" + @click="onClick"> + <template #icon> + <ConversationIcon :item="item" :hide-favorite="!item?.attendeeId" :hide-call="!item?.attendeeId" /> + </template> + <template v-if="conversationInformation" #subname> + {{ conversationInformation }} + </template> + </NcListItem> +</template> + +<script> +import { inject, toRefs } from 'vue' + +import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js' + +import ConversationIcon from './../../ConversationIcon.vue' + +import { useConversationInfo } from '../../../composables/useConversationInfo.js' + +export default { + name: 'ConversationSearchResult', + + components: { + ConversationIcon, + NcListItem, + }, + + props: { + exposeMessages: { + type: Boolean, + default: false, + }, + item: { + type: Object, + default() { + return { + token: '', + participants: [], + participantType: 0, + unreadMessages: 0, + unreadMention: false, + objectType: '', + type: 0, + displayName: '', + isFavorite: false, + notificationLevel: 0, + lastMessage: {}, + } + }, + }, + }, + + emits: ['click'], + + setup(props) { + const { item, exposeMessages } = toRefs(props) + const selectedRoom = inject('selectedRoom', null) + const { counterType, conversationInformation } = useConversationInfo({ item, exposeMessages }) + + return { + selectedRoom, + counterType, + conversationInformation, + } + }, + + methods: { + onClick() { + this.$emit('click', this.item) + }, + }, +} +</script> diff --git a/src/components/LeftSidebar/ConversationsListVirtual.vue b/src/components/LeftSidebar/ConversationsList/ConversationsListVirtual.vue index 66acca231..1a9e73cbd 100644 --- a/src/components/LeftSidebar/ConversationsListVirtual.vue +++ b/src/components/LeftSidebar/ConversationsList/ConversationsListVirtual.vue @@ -37,8 +37,8 @@ <script> import { RecycleScroller } from 'vue-virtual-scroller' -import Conversation from './ConversationsList/Conversation.vue' -import LoadingPlaceholder from '../LoadingPlaceholder.vue' +import Conversation from './Conversation.vue' +import LoadingPlaceholder from '../../LoadingPlaceholder.vue' import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' diff --git a/src/components/LeftSidebar/ConversationsList/ConversationsSearchListVirtual.vue b/src/components/LeftSidebar/ConversationsList/ConversationsSearchListVirtual.vue new file mode 100644 index 000000000..98b204a42 --- /dev/null +++ b/src/components/LeftSidebar/ConversationsList/ConversationsSearchListVirtual.vue @@ -0,0 +1,86 @@ +<!-- + - @copyright Copyright (c) 2024 Maksim Sukharev <antreesy.web@gmail.com> + - + - @author Grigorii Shartsev <me@shgk.me> + - @author Maksim Sukharev <antreesy.web@gmail.com> + - + - @license AGPL-3.0-or-later + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <RecycleScroller ref="scroller" + item-tag="ul" + :items="conversations" + :item-size="CONVERSATION_ITEM_SIZE" + key-field="token"> + <template #default="{ item }"> + <ConversationSearchResult :item="item" :expose-messages="exposeMessages" @click="onClick" /> + </template> + <template #after> + <LoadingPlaceholder v-if="loading" type="conversations" /> + </template> + </RecycleScroller> +</template> + +<script> +import { RecycleScroller } from 'vue-virtual-scroller' + +import ConversationSearchResult from './ConversationSearchResult.vue' +import LoadingPlaceholder from '../../LoadingPlaceholder.vue' + +import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' + +const CONVERSATION_ITEM_SIZE = 66 + +export default { + name: 'ConversationsSearchListVirtual', + + components: { + LoadingPlaceholder, + ConversationSearchResult, + RecycleScroller, + }, + + props: { + conversations: { + type: Array, + required: true, + }, + exposeMessages: { + type: Boolean, + default: false, + }, + loading: { + type: Boolean, + default: false, + }, + }, + + emits: ['select'], + + setup() { + return { + CONVERSATION_ITEM_SIZE, + } + }, + + methods: { + onClick(item) { + this.$emit('select', item) + }, + }, +} +</script> diff --git a/src/components/LeftSidebar/LeftSidebar.vue b/src/components/LeftSidebar/LeftSidebar.vue index ebdf3d4e1..729b70bb5 100644 --- a/src/components/LeftSidebar/LeftSidebar.vue +++ b/src/components/LeftSidebar/LeftSidebar.vue @@ -297,7 +297,7 @@ import { useIsMobile } from '@nextcloud/vue/dist/Composables/useIsMobile.js' import CallPhoneDialog from './CallPhoneDialog/CallPhoneDialog.vue' import Conversation from './ConversationsList/Conversation.vue' -import ConversationsListVirtual from './ConversationsListVirtual.vue' +import ConversationsListVirtual from './ConversationsList/ConversationsListVirtual.vue' import OpenConversationsList from './OpenConversationsList/OpenConversationsList.vue' import SearchBox from './SearchBox/SearchBox.vue' import ConversationIcon from '../ConversationIcon.vue' diff --git a/src/components/RoomSelector.spec.js b/src/components/RoomSelector.spec.js index a97e47484..9b62877fb 100644 --- a/src/components/RoomSelector.spec.js +++ b/src/components/RoomSelector.spec.js @@ -6,15 +6,33 @@ import { generateOcsUrl } from '@nextcloud/router' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import ConversationSearchResult from './LeftSidebar/ConversationsList/ConversationSearchResult.vue' import RoomSelector from './RoomSelector.vue' import { CONVERSATION } from '../constants.js' +import { generateOCSResponse } from '../test-helpers.js' jest.mock('@nextcloud/axios', () => ({ get: jest.fn(), })) -describe('RoomSelector.vue', () => { +const ConversationsSearchListVirtualStub = { + props: { + conversations: Array, + isSearchResult: Boolean, + }, + components: { + ConversationSearchResult, + }, + template: `<ul> + <ConversationSearchResult v-for="conversation in conversations" + :key="conversation.token" + :item="conversation" + @click="$emit('select', $event)"/> + </ul>`, +} + +describe('RoomSelector', () => { let conversations beforeEach(() => { @@ -29,6 +47,7 @@ describe('RoomSelector.vue', () => { token: 'token-1', displayName: 'conversation one', type: CONVERSATION.TYPE.PUBLIC, + listable: CONVERSATION.LISTABLE.USERS, lastActivity: 1, isFavorite: true, readOnly: CONVERSATION.STATE.READ_WRITE, @@ -36,6 +55,7 @@ describe('RoomSelector.vue', () => { token: 'token-2', displayName: 'abc', type: CONVERSATION.TYPE.GROUP, + listable: CONVERSATION.LISTABLE.USERS, lastActivity: 2, isFavorite: false, readOnly: CONVERSATION.STATE.READ_ONLY, @@ -69,82 +89,161 @@ describe('RoomSelector.vue', () => { }, }, } - - axios.get.mockResolvedValue({ - data: { - ocs: { - data: conversations, - }, - }, - }) }) + afterEach(() => { jest.clearAllMocks() }) - test('renders sorted conversation list fetched from server', async () => { - const wrapper = shallowMount(RoomSelector) - - expect(axios.get).toHaveBeenCalledWith( - generateOcsUrl('/apps/spreed/api/v4/room'), - { params: { includeStatus: true } } - ) + const mountRoomSelector = async (props) => { + const payload = conversations.filter(conv => { + return !props?.listOpenConversations || conv.listable === CONVERSATION.LISTABLE.USERS + }) - // need to wait for re-render, otherwise the list is not rendered yet - await flushPromises() + axios.get.mockResolvedValue(generateOCSResponse({ payload })) - const list = wrapper.findAll('li') - expect(list.length).toBe(3) - expect(list.at(0).text()).toBe('conversation one') - expect(list.at(1).text()).toBe('zzz') - expect(list.at(2).text()).toBe('abc') - }) - test('excludes non-postable conversations', async () => { const wrapper = shallowMount(RoomSelector, { - propsData: { - showPostableOnly: true, + stubs: { + ConversationsSearchListVirtual: ConversationsSearchListVirtualStub, + ConversationSearchResult, }, + propsData: props, }) - expect(axios.get).toHaveBeenCalledWith( - generateOcsUrl('/apps/spreed/api/v4/room'), - { params: { includeStatus: true } } - ) - // need to wait for re-render, otherwise the list is not rendered yet await flushPromises() - const list = wrapper.findAll('li') - expect(list.length).toBe(2) - expect(list.at(0).text()).toBe('conversation one') - expect(list.at(1).text()).toBe('zzz') - }) - test('emits select event on select', async () => { - const wrapper = shallowMount(RoomSelector) + return wrapper + } + + describe('rendering', () => { + it('renders sorted conversations list fetched from server', async () => { + // Arrange + const wrapper = await mountRoomSelector() + expect(axios.get).toHaveBeenCalledWith( + generateOcsUrl('/apps/spreed/api/v4/room'), + { params: { includeStatus: true } } + ) + + // Assert + const list = wrapper.findAllComponents({ name: 'NcListItem' }) + expect(list).toHaveLength(3) + expect(list.at(0).props('name')).toBe(conversations[1].displayName) + expect(list.at(1).props('name')).toBe(conversations[0].displayName) + expect(list.at(2).props('name')).toBe(conversations[2].displayName) + }) - expect(axios.get).toHaveBeenCalledWith( - generateOcsUrl('/apps/spreed/api/v4/room'), - { params: { includeStatus: true } } - ) - await flushPromises() + it('renders open conversations list fetched from server', async () => { + // Arrange + const wrapper = await mountRoomSelector({ listOpenConversations: true }) + expect(axios.get).toHaveBeenCalledWith( + generateOcsUrl('/apps/spreed/api/v4/listed-room'), + { params: { searchTerm: '' } } + ) + + // Assert + const list = wrapper.findAllComponents({ name: 'NcListItem' }) + expect(list).toHaveLength(2) + expect(list.at(0).props('name')).toBe(conversations[1].displayName) + expect(list.at(1).props('name')).toBe(conversations[2].displayName) + }) - const eventHandler = jest.fn() - wrapper.vm.$root.$on('select', eventHandler) + it('excludes non-postable conversations', async () => { + // Arrange + const wrapper = await mountRoomSelector({ showPostableOnly: true }) + + // Assert + const list = wrapper.findAllComponents({ name: 'NcListItem' }) + expect(list).toHaveLength(2) + expect(list.at(0).props('name')).toBe(conversations[1].displayName) + expect(list.at(1).props('name')).toBe(conversations[0].displayName) + }) - const list = wrapper.findAll('li') - await list.at(1).trigger('click') - await wrapper.findComponent(NcButton).vm.$emit('click') + it('filters conversations by displayName', async () => { + // Arrange + con |