Skip to content

Commit 64f3b31

Browse files
committed
fix(ConversationsListVirtual): migrate to useVirtualList from vueuse/core
Signed-off-by: Maksim Sukharev <[email protected]>
1 parent c14e102 commit 64f3b31

File tree

2 files changed

+123
-141
lines changed

2 files changed

+123
-141
lines changed

src/components/LeftSidebar/ConversationsList/ConversationsListVirtual.vue

Lines changed: 115 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -3,153 +3,130 @@
33
- SPDX-License-Identifier: AGPL-3.0-or-later
44
-->
55

6-
<template>
7-
<RecycleScroller
8-
ref="scroller"
9-
item-tag="ul"
10-
:items="conversations"
11-
:item-size="itemSize"
12-
key-field="token">
13-
<template #default="{ item }">
14-
<ConversationItem :item="item" :compact="compact" />
15-
</template>
16-
<template #after>
17-
<LoadingPlaceholder v-if="loading" type="conversations" />
18-
</template>
19-
</RecycleScroller>
20-
</template>
6+
<script setup lang="ts">
7+
import type { Conversation } from '../../../types/index.ts'
218
22-
<script>
23-
import { computed } from 'vue'
24-
import { RecycleScroller } from 'vue-virtual-scroller'
9+
import { useVirtualList } from '@vueuse/core'
10+
import { computed, toRef } from 'vue'
2511
import LoadingPlaceholder from '../../UIShared/LoadingPlaceholder.vue'
2612
import ConversationItem from './ConversationItem.vue'
2713
import { AVATAR } from '../../../constants.ts'
2814
29-
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
30-
31-
export default {
32-
name: 'ConversationsListVirtual',
33-
34-
components: {
35-
LoadingPlaceholder,
36-
ConversationItem,
37-
RecycleScroller,
38-
},
39-
40-
props: {
41-
conversations: {
42-
type: Array,
43-
required: true,
44-
},
45-
46-
loading: {
47-
type: Boolean,
48-
default: false,
49-
},
50-
51-
compact: {
52-
type: Boolean,
53-
default: false,
54-
},
55-
},
56-
57-
setup(props) {
58-
/* Consider:
59-
* avatar size (and two lines of text) or compact mode (28px)
60-
* list-item padding
61-
* list-item__wrapper padding
62-
*/
63-
const itemSize = computed(() => props.compact ? 28 + 2 * 2 + 0 * 2 : AVATAR.SIZE.DEFAULT + 2 * 4 + 2 * 2)
64-
return {
65-
itemSize,
15+
const props = defineProps<{
16+
conversations: Conversation[]
17+
loading?: boolean
18+
compact?: boolean
19+
}>()
20+
21+
/**
22+
* Consider:
23+
* avatar size (and two lines of text) or compact mode (28px)
24+
* list-item padding
25+
* list-item__wrapper padding
26+
*/
27+
const itemHeight = computed(() => props.compact ? 28 + 2 * 2 : AVATAR.SIZE.DEFAULT + 2 * 4 + 2 * 2)
28+
29+
const { list, containerProps, wrapperProps } = useVirtualList<Conversation>(toRef(() => props.conversations), {
30+
itemHeight: itemHeight.value,
31+
overscan: 10,
32+
})
33+
34+
/**
35+
* Get an index of the first fully visible conversation in viewport
36+
* Math.ceil to include partially of (absolute number of items above viewport) + 1 (next item is in viewport) - 1 (index starts from 0)
37+
*/
38+
function getFirstItemInViewportIndex(): number {
39+
return Math.ceil(containerProps.ref.value!.scrollTop / itemHeight.value)
40+
}
41+
42+
/**
43+
* Get an index of the last fully visible conversation in viewport
44+
* Math.floor to include only fully visible of (absolute number of items below and in viewport) - 1 (index starts from 0)
45+
*/
46+
function getLastItemInViewportIndex(): number {
47+
return Math.floor((containerProps.ref.value!.scrollTop + containerProps.ref.value!.clientHeight) / itemHeight.value) - 1
48+
}
49+
50+
/**
51+
* Scroll to conversation by index
52+
*
53+
* @param index - index of conversation to scroll to
54+
*/
55+
function scrollToItem(index: number) {
56+
const firstItemIndex = getFirstItemInViewportIndex()
57+
const lastItemIndex = getLastItemInViewportIndex()
58+
59+
const viewportHeight = containerProps.ref.value!.clientHeight
60+
61+
/**
62+
* Scroll to a position with smooth scroll imitation
63+
*
64+
* @param to - target position (in px)
65+
*/
66+
const doScroll = (to: number) => {
67+
const ITEMS_TO_BORDER_AFTER_SCROLL = 1
68+
const padding = ITEMS_TO_BORDER_AFTER_SCROLL * itemHeight.value
69+
const from = containerProps.ref.value!.scrollTop
70+
const direction = from < to ? 1 : -1
71+
72+
// If we are far from the target - instantly scroll to a close position
73+
if (Math.abs(from - to) > viewportHeight) {
74+
containerProps.ref.value!.scrollTo({
75+
top: to - direction * viewportHeight,
76+
behavior: 'instant',
77+
})
6678
}
67-
},
68-
69-
methods: {
70-
/**
71-
* Get an index of the first fully visible conversation in viewport
72-
*
73-
* @public
74-
* @return {number}
75-
*/
76-
getFirstItemInViewportIndex() {
77-
// (ceil to include partially) of (absolute number of items above viewport) + 1 (next item is in viewport) - 1 (index starts from 0)
78-
return Math.ceil(this.$refs.scroller.$el.scrollTop / this.itemSize)
79-
},
80-
81-
/**
82-
* Get an index of the last fully visible conversation in viewport
83-
*
84-
* @public
85-
* @return {number}
86-
*/
87-
getLastItemInViewportIndex() {
88-
// (floor to include only fully visible) of (absolute number of items below and in viewport) - 1 (index starts from 0)
89-
return Math.floor((this.$refs.scroller.$el.scrollTop + this.$refs.scroller.$el.clientHeight) / this.itemSize) - 1
90-
},
91-
92-
/**
93-
* Scroll to conversation by index
94-
*
95-
* @public
96-
* @param {number} index - index of conversation to scroll to
97-
* @return {Promise<void>}
98-
*/
99-
async scrollToItem(index) {
100-
const firstItemIndex = this.getFirstItemInViewportIndex()
101-
const lastItemIndex = this.getLastItemInViewportIndex()
102-
103-
const viewportHeight = this.$refs.scroller.$el.clientHeight
104-
105-
/**
106-
* Scroll to a position with smooth scroll imitation
107-
*
108-
* @param {number} to - target position
109-
* @return {void}
110-
*/
111-
const doScroll = (to) => {
112-
const ITEMS_TO_BORDER_AFTER_SCROLL = 1
113-
const padding = ITEMS_TO_BORDER_AFTER_SCROLL * this.itemSize
114-
const from = this.$refs.scroller.$el.scrollTop
115-
const direction = from < to ? 1 : -1
116-
117-
// If we are far from the target - instantly scroll to a close position
118-
if (Math.abs(from - to) > viewportHeight) {
119-
this.$refs.scroller.scrollToPosition(to - direction * viewportHeight)
120-
}
121-
122-
// Scroll to the target with smooth scroll
123-
this.$refs.scroller.$el.scrollTo({
124-
top: to + padding * direction,
125-
behavior: 'smooth',
126-
})
127-
}
128-
129-
if (index < firstItemIndex) { // Item is above
130-
await doScroll(index * this.itemSize)
131-
} else if (index > lastItemIndex) { // Item is below
132-
// Position of item + item's height and move to bottom
133-
await doScroll((index + 1) * this.itemSize - viewportHeight)
134-
}
135-
},
136-
137-
/**
138-
* Scroll to conversation by token
139-
*
140-
* @param {string} token - token of conversation to scroll to
141-
* @return {void}
142-
*/
143-
scrollToConversation(token) {
144-
const index = this.conversations.findIndex((conversation) => conversation.token === token)
145-
if (index !== -1) {
146-
this.scrollToItem(index)
147-
}
148-
},
149-
},
79+
80+
// Scroll to the target with smooth scroll
81+
containerProps.ref.value!.scrollTo({
82+
top: to + padding * direction,
83+
behavior: 'smooth',
84+
})
85+
}
86+
87+
if (index < firstItemIndex) { // Item is above
88+
doScroll(index * itemHeight.value)
89+
} else if (index > lastItemIndex) { // Item is below
90+
// Position of item + item's height and move to bottom
91+
doScroll((index + 1) * itemHeight.value - viewportHeight)
92+
}
15093
}
94+
95+
/**
96+
* Scroll to conversation by token
97+
*
98+
* @param token - token of conversation to scroll to
99+
*/
100+
function scrollToConversation(token: string) {
101+
const index = props.conversations.findIndex((conversation) => conversation.token === token)
102+
if (index !== -1) {
103+
scrollToItem(index)
104+
}
105+
}
106+
107+
defineExpose({
108+
getFirstItemInViewportIndex,
109+
getLastItemInViewportIndex,
110+
scrollToItem,
111+
scrollToConversation,
112+
})
151113
</script>
152114

115+
<template>
116+
<li v-bind="containerProps">
117+
<LoadingPlaceholder v-if="loading" type="conversations" />
118+
<ul
119+
v-else
120+
v-bind="wrapperProps">
121+
<ConversationItem
122+
v-for="item in list"
123+
:key="item.data.id"
124+
:item="item.data"
125+
:compact="compact" />
126+
</ul>
127+
</li>
128+
</template>
129+
153130
<style lang="scss" scoped>
154131
// Overwrite NcListItem styles
155132
:deep(.list-item) {

src/components/LeftSidebar/LeftSidebar.spec.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ describe('LeftSidebar.vue', () => {
3838
let testStoreConfig
3939
let loadStateSettings
4040
let conversationsListMock
41+
let conversationsInitialisedMock
4142
let fetchConversationsAction
4243
let addConversationAction
4344
let createOneToOneConversationAction
@@ -47,6 +48,7 @@ describe('LeftSidebar.vue', () => {
4748
const ComponentStub = {
4849
template: '<div><slot /></div>',
4950
}
51+
// TODO remove
5052
const RecycleScrollerStub = {
5153
props: {
5254
items: Array,
@@ -73,6 +75,7 @@ describe('LeftSidebar.vue', () => {
7375
// to prevent complex dialog logic
7476
NcActions: ComponentStub,
7577
NcModal: ComponentStub,
78+
// TODO remove
7679
RecycleScroller: RecycleScrollerStub,
7780
},
7881
provide: {
@@ -104,11 +107,13 @@ describe('LeftSidebar.vue', () => {
104107

105108
// note: need a copy because the Vue modifies it when sorting
106109
conversationsListMock = vi.fn()
110+
conversationsInitialisedMock = vi.fn(() => true)
107111
fetchConversationsAction = vi.fn().mockReturnValue({ headers: {} })
108112
addConversationAction = vi.fn()
109113
createOneToOneConversationAction = vi.fn()
110114
actorStore.setCurrentUser({ uid: 'current-user' })
111115
testStoreConfig.modules.conversationsStore.getters.conversationsList = conversationsListMock
116+
testStoreConfig.modules.conversationsStore.getters.conversationsInitialised = conversationsInitialisedMock
112117
testStoreConfig.modules.conversationsStore.actions.fetchConversations = fetchConversationsAction
113118
testStoreConfig.modules.conversationsStore.actions.addConversation = addConversationAction
114119
testStoreConfig.modules.conversationsStore.actions.createOneToOneConversation = createOneToOneConversationAction
@@ -174,10 +179,10 @@ describe('LeftSidebar.vue', () => {
174179
await flushPromises()
175180

176181
const normalConversationsList = conversationsList.filter((conversation) => !conversation.isArchived)
177-
const conversationListItems = wrapper.findAll('.vue-recycle-scroller-STUB-item')
182+
const conversationListItems = wrapper.findAll('.conversation')
178183
expect(conversationListItems).toHaveLength(normalConversationsList.length)
179-
expect(conversationListItems.at(0).text()).toStrictEqual(normalConversationsList[0].displayName)
180-
expect(conversationListItems.at(1).text()).toStrictEqual(normalConversationsList[1].displayName)
184+
expect(conversationListItems.at(0).text()).toContain(normalConversationsList[0].displayName)
185+
expect(conversationListItems.at(1).text()).toContain(normalConversationsList[1].displayName)
181186

182187
expect(conversationsReceivedEvent).toHaveBeenCalled()
183188
})

0 commit comments

Comments
 (0)