fix(webapp): fix group performance (#8656)

* seed more Yoga group members

* implement groupMembers pagination

* load limited amount of group members

* force show all members in group member list

* remove unused import

* - added virtual scrolling to ProfileList

* - fixed linter error

* load all when clicking the button

* seed 3000 users

* cleanup

* lint

* hide search when not all members are visible

* fix email factory

* - increased profileListVisibleCount to 6

---------

Co-authored-by: Sebastian Stein <sebastian@codepassion.de>
This commit is contained in:
Ulf Gebhardt 2025-06-11 17:46:57 +02:00 committed by GitHub
parent 6ae392b6e3
commit a0e4b49833
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 152 additions and 65 deletions

View File

@ -11,6 +11,9 @@ import slugify from 'slug'
import { v4 as uuid } from 'uuid'
import { generateInviteCode } from '@graphql/resolvers/inviteCodes'
import { isUniqueFor } from '@middleware/sluggifyMiddleware'
import uniqueSlug from '@middleware/slugify/uniqueSlug'
import { Context } from '@src/server'
import { getDriver, getNeode } from './neo4j'
@ -22,8 +25,9 @@ const uniqueImageUrl = (imageUrl) => {
return newUrl.toString()
}
const driver = getDriver()
export const cleanDatabase = async ({ withMigrations } = { withMigrations: false }) => {
const driver = getDriver()
const session = driver.session()
const clean = `
@ -89,9 +93,7 @@ Factory.define('basicUser')
showShoutsPublicly: false,
locale: 'en',
})
.attr('slug', ['slug', 'name'], (slug, name) => {
return slug || slugify(name, { lower: true })
})
.attr('slug', null)
.attr('encryptedPassword', ['password'], (password) => {
// eslint-disable-next-line n/no-sync
return hashSync(password, 10)
@ -121,13 +123,24 @@ Factory.define('userWithAboutEmpty')
Factory.define('user')
.extend('basicUser')
.option('about', faker.lorem.paragraph)
.option('email', faker.internet.exampleEmail)
.option('email', null)
.option('avatar', () =>
Factory.build('image', {
url: faker.image.avatar(),
}),
)
.after(async (buildObject, options) => {
// Ensure unique slug
if (!buildObject.slug) {
buildObject.slug = await uniqueSlug(
buildObject.name,
isUniqueFor({ driver } as unknown as Context, 'User'),
)
}
// Ensure unique email
if (!options.email) {
options.email = `${buildObject.slug as string}@example.org`
}
const [user, email, avatar] = await Promise.all([
neode.create('User', buildObject),
neode.create('EmailAddress', { email: options.email }),

View File

@ -1197,11 +1197,22 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const additionalUsers: any[] = []
for (let i = 0; i < 30; i++) {
for (let i = 0; i < 3000; i++) {
const user = await Factory.build('user')
await jennyRostock.relateTo(user, 'following')
await user.relateTo(jennyRostock, 'following')
additionalUsers.push(user)
const userObj = await user.toJson()
authenticatedUser = userObj
await mutate({
mutation: joinGroupMutation(),
variables: {
groupId: 'g2',
userId: userObj.id,
},
})
}
// Jenny users

View File

@ -80,15 +80,18 @@ export default {
}
},
GroupMembers: async (_object, params, context: Context, _resolveInfo) => {
const { id: groupId } = params
const { id: groupId, first = 25, offset = 0 } = params
const session = context.driver.session()
const readTxResultPromise = session.readTransaction(async (txc) => {
const groupMemberCypher = `
MATCH (user:User)-[membership:MEMBER_OF]->(:Group {id: $groupId})
RETURN user {.*, myRoleInGroup: membership.role}
SKIP toInteger($offset) LIMIT toInteger($first)
`
const transactionResponse = await txc.run(groupMemberCypher, {
groupId,
first,
offset,
})
return transactionResponse.records.map((record) => record.get('user'))
})
@ -468,6 +471,9 @@ export default {
isMutedByMe:
'MATCH (this) RETURN EXISTS( (this)<-[:MUTED]-(:User {id: $cypherParams.currentUserId}) )',
},
count: {
membersCount: '<-[:MEMBER_OF]-(related:User)',
},
}),
name: async (parent, _args, context: Context, _resolveInfo) => {
if (!context.user) {

View File

@ -38,6 +38,8 @@ type Group {
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
membersCount: Int! @cypher(statement: "MATCH (this)<-[:MEMBER_OF]-(r:User) RETURN COUNT(DISTINCT r)")
myRole: GroupMemberRole # if 'null' then the current user is no member
posts: [Post] @relation(name: "IN", direction: "IN")
@ -78,8 +80,8 @@ type Query {
GroupMembers(
id: ID!
# first: Int # not implemented yet
# offset: Int # not implemented yet
first: Int
offset: Int
# orderBy: [_UserOrdering] # not implemented yet
# filter: _UserFilter # not implemented yet
): [User]

View File

@ -5,7 +5,7 @@ import type { Context } from '@src/server'
import uniqueSlug from './slugify/uniqueSlug'
const isUniqueFor = (context: Context, type: string) => {
export const isUniqueFor = (context: Context, type: string) => {
return async (slug: string) => {
const session = context.driver.session()
try {

View File

@ -4,24 +4,44 @@
<h5 class="title spacer-x-small">
{{ title }}
</h5>
<ul :class="profilesClass">
<!-- Virtual Scroller for better performance -->
<recycle-scroller
v-if="isMoreAsVisible && showVirtualScroll"
:items="filteredConnections"
:item-size="itemHeight"
key-field="id"
:class="profilesClass"
class="profiles-virtual"
v-slot="{ item }"
>
<div class="connections__item">
<user-teaser :user="item" />
</div>
</recycle-scroller>
<!-- Normal list for only a few items -->
<ul v-else :class="profilesClass">
<li
v-for="connection in filteredConnections"
v-for="connection in displayedConnections"
:key="connection.id"
class="connections__item"
>
<user-teaser :user="connection" />
</li>
</ul>
<ds-input
v-if="isMoreAsVisible"
v-if="isMoreAsVisible && !hasMore"
:name="uniqueName"
:placeholder="filter"
:placeholder="filterPlaceholder"
:value="filter"
class="spacer-x-small"
icon="filter"
size="small"
@input.native="setFilter"
/>
<base-button
v-if="hasMore"
:loading="loading"
@ -42,14 +62,19 @@
<script>
import { escape } from 'xregexp/xregexp-all.js'
// @ts-ignore
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
export const profileListVisibleCount = 7
export const profileListVisibleCount = 6
const VIRTUAL_SCROLL_THRESHOLD = 50
export default {
name: 'ProfileList',
components: {
UserTeaser,
RecycleScroller,
},
props: {
uniqueName: { type: String, required: true },
@ -63,6 +88,8 @@ export default {
return {
profileListVisibleCount,
filter: null,
itemHeight: 56,
filterPlaceholder: this.$t('common.filter', 'Filter...'),
}
},
computed: {
@ -72,17 +99,34 @@ export default {
isMoreAsVisible() {
return this.profiles.length > this.profileListVisibleCount
},
showVirtualScroll() {
return process.client && this.filteredConnections.length > VIRTUAL_SCROLL_THRESHOLD
},
profilesClass() {
return `profiles${this.isMoreAsVisible ? ' --overflow' : ''}`
},
displayedConnections() {
return this.isMoreAsVisible
? this.filteredConnections
: this.filteredConnections.slice(0, this.profileListVisibleCount)
},
filteredConnections() {
if (!this.filter) {
return this.profiles
}
// @example
// this.filter = 'foo';
// fuzzyExpression = /([^f]*f)([^o]*o)([^o]*o)/i
const filterLower = this.filter.toLowerCase()
const simpleMatches = this.profiles.filter((user) => {
const name = (user.name || '').toLowerCase()
const slug = (user.slug || '').toLowerCase()
return name.includes(filterLower) || slug.includes(filterLower)
})
if (simpleMatches.length > 0) {
return simpleMatches
}
const fuzzyExpression = new RegExp(
`${this.filter.split('').reduce((expr, c) => `${expr}([^${escape(c)}]*${escape(c)})`, '')}`,
'i',
@ -151,6 +195,26 @@ export default {
}
}
.profiles-virtual {
height: $size-height-connections;
padding: $space-none;
&.--overflow {
overflow-y: auto;
}
.connections__item {
padding: $space-xx-small;
height: 56px;
display: flex;
align-items: center;
&:hover {
background-color: $background-color-primary-inverse;
}
}
}
.nobody-message {
text-align: center;
color: $text-color-soft;
@ -160,4 +224,8 @@ export default {
margin-bottom: $space-small;
}
}
.vue-recycle-scroller__item-wrapper {
overflow: visible;
}
</style>

View File

@ -194,6 +194,7 @@ export const groupQuery = (i18n) => {
lng
lat
}
membersCount
myRole
inviteCodes {
createdAt
@ -212,8 +213,8 @@ export const groupQuery = (i18n) => {
export const groupMembersQuery = () => {
return gql`
query ($id: ID!) {
GroupMembers(id: $id) {
query ($id: ID!, $first: Int, $offset: Int) {
GroupMembers(id: $id, first: $first, offset: $offset) {
id
name
slug

View File

@ -61,6 +61,7 @@
"vue-observe-visibility": "^1.0.0",
"vue-scrollto": "^2.20.0",
"vue-sweetalert-icons": "~4.3.1",
"vue-virtual-scroller": "^1.1.2",
"vue2-datepicker": "^3.11.1",
"vuex-i18n": "~1.13.1",
"xregexp": "^5.1.2",

View File

@ -55,29 +55,11 @@
<count-to
slot="count"
:start-val="membersCountStartValue"
:end-val="groupMembers.length"
:end-val="group.membersCount"
/>
</ds-number>
</client-only>
</ds-flex-item>
<!-- <ds-flex-item>
<client-only>
<ds-number :label="$t('profile.followers')">
<count-to
slot="count"
:start-val="followedByCountStartValue"
:end-val="user.followedByCount"
/>
</ds-number>
</client-only>
</ds-flex-item> -->
<!-- <ds-flex-item>
<client-only>
<ds-number :label="$t('profile.following')">
<count-to slot="count" :end-val="user.followingCount" />
</ds-number>
</client-only>
</ds-flex-item> -->
</ds-flex>
<div class="action-buttons">
<base-button danger v-if="group.isMutedByMe" @click="unmuteGroup" icon="volume-up">
@ -194,25 +176,11 @@
? $t('group.membersListTitleNotAllowedSeeingGroupMembers')
: null
"
:allProfilesCount="isAllowedSeeingGroupMembers ? groupMembers.length : 0"
:allProfilesCount="isAllowedSeeingGroupMembers ? group.membersCount : 0"
:profiles="isAllowedSeeingGroupMembers ? groupMembers : []"
:loading="$apollo.loading"
@fetchAllProfiles="fetchAllMembers"
/>
<!-- <ds-space />
<follow-list
:loading="$apollo.loading"
:user="user"
type="followedBy"
@fetchAllConnections="fetchAllConnections"
/>
<ds-space />
<follow-list
:loading="$apollo.loading"
:user="user"
type="following"
@fetchAllConnections="fetchAllConnections"
/> -->
<!-- <social-media :user-name="groupName" :user="user" /> -->
</ds-flex-item>
@ -310,8 +278,6 @@ import Category from '~/components/Category'
import ContentViewer from '~/components/Editor/ContentViewer'
import CountTo from '~/components/CountTo.vue'
import Empty from '~/components/Empty/Empty'
// import FollowButton from '~/components/Button/FollowButton'
// import FollowList from '~/components/features/ProfileList/FollowList'
import GroupContentMenu from '~/components/ContentMenu/GroupContentMenu'
import JoinLeaveButton from '~/components/Button/JoinLeaveButton'
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue'
@ -340,8 +306,6 @@ export default {
ContentViewer,
CountTo,
Empty,
// FollowButton,
// FollowList,
GroupContentMenu,
JoinLeaveButton,
PostTeaser,
@ -373,11 +337,8 @@ export default {
pageSize: 6,
// tabActive: 'post',
filter,
// followedByCountStartValue: 0,
// followedByCount: 7,
// followingCount: 7,
membersCountStartValue: 0,
membersCountToLoad: Infinity,
membersCountToLoad: 25,
updateGroupMutation,
isDescriptionCollapsed: true,
}
@ -593,7 +554,7 @@ export default {
}
},
fetchAllMembers() {
this.membersCountToLoad = Infinity
this.membersCountToLoad = this.group.membersCount
},
},
apollo: {
@ -621,8 +582,6 @@ export default {
variables() {
return {
id: this.$route.params.id,
// followedByCount: this.followedByCount,
// followingCount: this.followingCount,
}
},
error(error) {
@ -637,6 +596,7 @@ export default {
variables() {
return {
id: this.$route.params.id,
first: this.membersCountToLoad,
}
},
skip() {

View File

@ -45,6 +45,7 @@ export default {
variables() {
return {
id: this.group.id,
first: 999999,
}
},
error(error) {

View File

@ -17417,6 +17417,11 @@ schema-utils@^3.0.0:
ajv "^6.12.5"
ajv-keywords "^3.5.2"
scrollparent@^2.0.1:
version "2.1.0"
resolved "https://registry.yarnpkg.com/scrollparent/-/scrollparent-2.1.0.tgz#6cae915c953835886a6ba0d77fdc2bb1ed09076d"
integrity sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==
select@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d"
@ -19825,11 +19830,21 @@ vue-no-ssr@^1.1.1:
resolved "https://registry.yarnpkg.com/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz#875f3be6fb0ae41568a837f3ac1a80eaa137b998"
integrity sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g==
vue-observe-visibility@^0.4.4:
version "0.4.6"
resolved "https://registry.yarnpkg.com/vue-observe-visibility/-/vue-observe-visibility-0.4.6.tgz#878cb8ebcf3078e40807af29774e97105ebd519e"
integrity sha512-xo0CEVdkjSjhJoDdLSvoZoQrw/H2BlzB5jrCBKGZNXN2zdZgMuZ9BKrxXDjNP2AxlcCoKc8OahI3F3r3JGLv2Q==
vue-observe-visibility@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/vue-observe-visibility/-/vue-observe-visibility-1.0.0.tgz#17cf1b2caf74022f0f3c95371468ddf2b9573152"
integrity sha512-s5TFh3s3h3Mhd3jaz3zGzkVHKHnc/0C/gNr30olO99+yw2hl3WBhK3ng3/f9OF+qkW4+l7GkmwfAzDAcY3lCFg==
vue-resize@^0.4.5:
version "0.4.5"
resolved "https://registry.yarnpkg.com/vue-resize/-/vue-resize-0.4.5.tgz#4777a23042e3c05620d9cbda01c0b3cc5e32dcea"
integrity sha512-bhP7MlgJQ8TIkZJXAfDf78uJO+mEI3CaLABLjv0WNzr4CcGRGPIAItyWYnP6LsPA4Oq0WE+suidNs6dgpO4RHg==
vue-resize@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/vue-resize/-/vue-resize-1.0.1.tgz#c120bed4e09938771d622614f57dbcf58a5147ee"
@ -19900,6 +19915,15 @@ vue-template-es2015-compiler@^1.9.0:
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825"
integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==
vue-virtual-scroller@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vue-virtual-scroller/-/vue-virtual-scroller-1.1.2.tgz#b8a6362f177abf3f2149ce1eac18013c71fe353b"
integrity sha512-SkUyc7QHCJFB5h1Fya7LxVizlVzOZZuFVipBGHYoTK8dwLs08bIz/tclvRApYhksaJIm/nn51inzO2UjpGJPMQ==
dependencies:
scrollparent "^2.0.1"
vue-observe-visibility "^0.4.4"
vue-resize "^0.4.5"
vue2-datepicker@^3.11.1:
version "3.11.1"
resolved "https://registry.yarnpkg.com/vue2-datepicker/-/vue2-datepicker-3.11.1.tgz#b2124e15f694d0fd43a92558f6929ec29338d241"