diff --git a/package-lock.json b/package-lock.json
index b26a7bcb8db..08799474e4b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -49,6 +49,7 @@
"vue-prevent-unload": "^0.2.3",
"vue-router": "^3.6.5",
"vue-shortkey": "^3.1.7",
+ "vue-virtual-scroller": "^1.1.2",
"vue2-leaflet": "^2.7.1",
"vuex": "^3.6.2",
"webdav": "^5.2.3",
@@ -15172,6 +15173,11 @@
"dev": true,
"peer": true
},
+ "node_modules/scrollparent": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
+ "integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="
+ },
"node_modules/sdp": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz",
@@ -17336,6 +17342,32 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true
},
+ "node_modules/vue-virtual-scroller": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-1.1.2.tgz",
+ "integrity": "sha512-SkUyc7QHCJFB5h1Fya7LxVizlVzOZZuFVipBGHYoTK8dwLs08bIz/tclvRApYhksaJIm/nn51inzO2UjpGJPMQ==",
+ "dependencies": {
+ "scrollparent": "^2.0.1",
+ "vue-observe-visibility": "^0.4.4",
+ "vue-resize": "^0.4.5"
+ },
+ "peerDependencies": {
+ "vue": "^2.6.11"
+ }
+ },
+ "node_modules/vue-virtual-scroller/node_modules/vue-observe-visibility": {
+ "version": "0.4.6",
+ "resolved": "https://registry.npmjs.org/vue-observe-visibility/-/vue-observe-visibility-0.4.6.tgz",
+ "integrity": "sha512-xo0CEVdkjSjhJoDdLSvoZoQrw/H2BlzB5jrCBKGZNXN2zdZgMuZ9BKrxXDjNP2AxlcCoKc8OahI3F3r3JGLv2Q=="
+ },
+ "node_modules/vue-virtual-scroller/node_modules/vue-resize": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-0.4.5.tgz",
+ "integrity": "sha512-bhP7MlgJQ8TIkZJXAfDf78uJO+mEI3CaLABLjv0WNzr4CcGRGPIAItyWYnP6LsPA4Oq0WE+suidNs6dgpO4RHg==",
+ "peerDependencies": {
+ "vue": "^2.3.0"
+ }
+ },
"node_modules/vue2-datepicker": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/vue2-datepicker/-/vue2-datepicker-3.11.0.tgz",
@@ -29358,6 +29390,11 @@
}
}
},
+ "scrollparent": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
+ "integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="
+ },
"sdp": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz",
@@ -31035,6 +31072,29 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true
},
+ "vue-virtual-scroller": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-1.1.2.tgz",
+ "integrity": "sha512-SkUyc7QHCJFB5h1Fya7LxVizlVzOZZuFVipBGHYoTK8dwLs08bIz/tclvRApYhksaJIm/nn51inzO2UjpGJPMQ==",
+ "requires": {
+ "scrollparent": "^2.0.1",
+ "vue-observe-visibility": "^0.4.4",
+ "vue-resize": "^0.4.5"
+ },
+ "dependencies": {
+ "vue-observe-visibility": {
+ "version": "0.4.6",
+ "resolved": "https://registry.npmjs.org/vue-observe-visibility/-/vue-observe-visibility-0.4.6.tgz",
+ "integrity": "sha512-xo0CEVdkjSjhJoDdLSvoZoQrw/H2BlzB5jrCBKGZNXN2zdZgMuZ9BKrxXDjNP2AxlcCoKc8OahI3F3r3JGLv2Q=="
+ },
+ "vue-resize": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-0.4.5.tgz",
+ "integrity": "sha512-bhP7MlgJQ8TIkZJXAfDf78uJO+mEI3CaLABLjv0WNzr4CcGRGPIAItyWYnP6LsPA4Oq0WE+suidNs6dgpO4RHg==",
+ "requires": {}
+ }
+ }
+ },
"vue2-datepicker": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/vue2-datepicker/-/vue2-datepicker-3.11.0.tgz",
diff --git a/package.json b/package.json
index d210e799043..af5abfe2209 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
"vue-prevent-unload": "^0.2.3",
"vue-router": "^3.6.5",
"vue-shortkey": "^3.1.7",
+ "vue-virtual-scroller": "^1.1.2",
"vue2-leaflet": "^2.7.1",
"vuex": "^3.6.2",
"webdav": "^5.2.3",
diff --git a/src/components/ConversationIcon.vue b/src/components/ConversationIcon.vue
index 014c0b0f106..1340fe377b4 100644
--- a/src/components/ConversationIcon.vue
+++ b/src/components/ConversationIcon.vue
@@ -27,9 +27,14 @@
-
+
+
+ -
+ - @author Grigorii Shartsev
+ -
+ - @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 .
+ -->
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/LeftSidebar/LeftSidebar.vue b/src/components/LeftSidebar/LeftSidebar.vue
index 49b8294a58b..a1f459f610a 100644
--- a/src/components/LeftSidebar/LeftSidebar.vue
+++ b/src/components/LeftSidebar/LeftSidebar.vue
@@ -111,17 +111,25 @@
-
@@ -204,13 +214,6 @@
-
-
- {{ t('spreed', 'Unread mentions') }}
-
@@ -252,9 +255,9 @@ import isMobile from '@nextcloud/vue/dist/Mixins/isMobile.js'
import ConversationIcon from '../ConversationIcon.vue'
import Hint from '../Hint.vue'
-import LoadingPlaceholder from '../LoadingPlaceholder.vue'
import TransitionWrapper from '../TransitionWrapper.vue'
import Conversation from './ConversationsList/Conversation.vue'
+import ConversationsListVirtual from './ConversationsListVirtual.vue'
import NewGroupConversation from './NewGroupConversation/NewGroupConversation.vue'
import OpenConversationsList from './OpenConversationsList/OpenConversationsList.vue'
import SearchBox from './SearchBox/SearchBox.vue'
@@ -284,12 +287,12 @@ export default {
NewGroupConversation,
OpenConversationsList,
Conversation,
- LoadingPlaceholder,
NcListItem,
ConversationIcon,
NcActions,
NcActionButton,
TransitionWrapper,
+ ConversationsListVirtual,
// Icons
AtIcon,
MessageBadge,
@@ -336,8 +339,10 @@ export default {
// Keeps track of whether the conversation list is scrolled to the top or not
isScrolledToTop: true,
refreshTimer: null,
- unreadNum: 0,
- firstUnreadPos: 0,
+ /**
+ * @type {number|null}
+ */
+ lastUnreadMentionBelowViewportIndex: null,
preventFindingUnread: false,
roomListModifiedBefore: 0,
forceFullRoomListRefreshAfterXLoops: 0,
@@ -422,11 +427,6 @@ export default {
},
beforeMount() {
- // After a conversation was created, the search filter is reset
- EventBus.$once('resetSearchFilter', () => {
- this.abortSearch()
- })
-
// Restore last fetched conversations from browser storage,
// before updated ones come from server
this.restoreConversations()
@@ -467,9 +467,8 @@ export default {
mounted() {
EventBus.$on('should-refresh-conversations', this.handleShouldRefreshConversations)
- EventBus.$once('conversations-received', this.handleUnreadMention)
+ EventBus.$once('conversations-received', this.handleConversationsReceived)
EventBus.$on('route-change', this.onRouteChange)
- EventBus.$on('joined-conversation', this.handleJoinedConversation)
},
beforeDestroy() {
@@ -513,10 +512,7 @@ export default {
scrollBottomUnread() {
this.preventFindingUnread = true
- this.$refs.container.scrollTo({
- top: this.firstUnreadPos - 150,
- behavior: 'smooth',
- })
+ this.$refs.scroller.scrollToItem(this.lastUnreadMentionBelowViewportIndex)
setTimeout(() => {
this.handleUnreadMention()
this.preventFindingUnread = false
@@ -605,6 +601,7 @@ export default {
if (item.source === 'users') {
// Create one-to-one conversation directly
const conversation = await this.$store.dispatch('createOneToOneConversation', item.id)
+ this.abortSearch()
this.$router.push({
name: 'conversation',
params: { token: conversation.token },
@@ -732,58 +729,48 @@ export default {
}
},
+ handleConversationsReceived() {
+ this.handleUnreadMention()
+ if (this.$route.params.token) {
+ this.scrollToConversation(this.$route.params.token)
+ }
+ },
+
// Checks whether the conversations list is scrolled all the way to the top
// or not
handleScroll() {
- this.isScrolledToTop = this.$refs.container.scrollTop === 0
- },
- elementIsAboveViewpoint(container, element) {
- return element.offsetTop < container.scrollTop
+ this.isScrolledToTop = this.$refs.scroller.$el.scrollTop === 0
},
- elementIsBelowViewpoint(container, element) {
- return element.offsetTop + element.offsetHeight > container.scrollTop + container.clientHeight
- },
- handleUnreadMention() {
- this.unreadNum = 0
- const unreadMentions = document.querySelectorAll('.unread-mention-conversation')
- unreadMentions.forEach(x => {
- if (this.elementIsBelowViewpoint(this.$refs.container, x)) {
- if (this.unreadNum === 0) {
- this.firstUnreadPos = x.offsetTop
- }
- this.unreadNum += 1
+
+ /**
+ * Find position of the last unread conversation below viewport
+ */
+ async handleUnreadMention() {
+ await this.$nextTick()
+
+ this.lastUnreadMentionBelowViewportIndex = null
+ const lastConversationInViewport = this.$refs.scroller.getLastItemInViewportIndex()
+ for (let i = this.filteredConversationsList.length - 1; i > lastConversationInViewport; i--) {
+ if (this.filteredConversationsList[i].unreadMention) {
+ this.lastUnreadMentionBelowViewportIndex = i
+ return
}
- })
+ }
},
+
debounceHandleScroll: debounce(function() {
this.handleScroll()
this.handleUnreadMention()
}, 50),
- scrollToConversation(token) {
- this.$nextTick(() => {
- // In Vue 2 ref on v-for is always an array and its order is not guaranteed to match the order of v-for source
- // See https://github.com/vuejs/vue/issues/4952#issuecomment-280661367
- // Fixed in Vue 3
- // Temp solution - use unique ref name for each v-for element. The value is still array but with one element
- // TODO: Vue3: remove [0] here or use object for template refs
- const conversation = this.$refs[`conversation-${token}`]?.[0].$el
- if (!conversation) {
- return
- }
+ async scrollToConversation(token) {
+ await this.$nextTick()
- if (this.elementIsBelowViewpoint(this.$refs.container, conversation)) {
- this.$refs.container.scrollTo({
- top: conversation.offsetTop + conversation.offsetHeight * 2 - this.$refs.container.clientHeight,
- behavior: 'smooth',
- })
- } else if (this.elementIsAboveViewpoint(this.$refs.container, conversation)) {
- this.$refs.container.scrollTo({
- top: conversation.offsetTop - conversation.offsetHeight,
- behavior: 'smooth',
- })
- }
- })
+ if (!this.$refs.scroller) {
+ return
+ }
+
+ this.$refs.scroller.scrollToConversation(token)
},
onRouteChange({ from, to }) {
@@ -801,6 +788,7 @@ export default {
}
if (to.name === 'conversation') {
this.$store.dispatch('joinConversation', { token: to.params.token })
+ this.scrollToConversation(to.params.token)
}
if (this.isMobile) {
emit('toggle-navigation', {
@@ -809,11 +797,6 @@ export default {
}
},
- handleJoinedConversation({ token }) {
- this.abortSearch()
- this.scrollToConversation(token)
- },
-
iconData(item) {
if (item.source === 'users') {
return {
@@ -838,6 +821,10 @@ export default {
padding: 0 4px 0 6px;
}
+.h-100 {
+ height: 100%;
+}
+
.new-conversation {
position: relative;
display: flex;