diff --git a/backend/src/db/factories.ts b/backend/src/db/factories.ts
index a5237dada..eef970aba 100644
--- a/backend/src/db/factories.ts
+++ b/backend/src/db/factories.ts
@@ -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 }),
diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts
index e7f5b23c5..f93bb6b98 100644
--- a/backend/src/db/seed.ts
+++ b/backend/src/db/seed.ts
@@ -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
diff --git a/backend/src/graphql/resolvers/groups.ts b/backend/src/graphql/resolvers/groups.ts
index 9e330bade..9efa8e6af 100644
--- a/backend/src/graphql/resolvers/groups.ts
+++ b/backend/src/graphql/resolvers/groups.ts
@@ -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) {
diff --git a/backend/src/graphql/types/type/Group.gql b/backend/src/graphql/types/type/Group.gql
index 0adc7853b..03b3e0eee 100644
--- a/backend/src/graphql/types/type/Group.gql
+++ b/backend/src/graphql/types/type/Group.gql
@@ -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]
diff --git a/backend/src/middleware/sluggifyMiddleware.ts b/backend/src/middleware/sluggifyMiddleware.ts
index 0a45521f0..fc38a5bfb 100644
--- a/backend/src/middleware/sluggifyMiddleware.ts
+++ b/backend/src/middleware/sluggifyMiddleware.ts
@@ -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 {
diff --git a/webapp/components/features/ProfileList/ProfileList.vue b/webapp/components/features/ProfileList/ProfileList.vue
index 29fdb2872..e7db8b3b4 100644
--- a/webapp/components/features/ProfileList/ProfileList.vue
+++ b/webapp/components/features/ProfileList/ProfileList.vue
@@ -4,24 +4,44 @@
{{ title }}
-
+
+
+
+
+
+
+
+
+
+
+
+
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;
+}
diff --git a/webapp/graphql/groups.js b/webapp/graphql/groups.js
index 5ce33407b..c0ba0aa89 100644
--- a/webapp/graphql/groups.js
+++ b/webapp/graphql/groups.js
@@ -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
diff --git a/webapp/package.json b/webapp/package.json
index 428277d4d..dfb9aec7e 100644
--- a/webapp/package.json
+++ b/webapp/package.json
@@ -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",
diff --git a/webapp/pages/groups/_id/_slug.vue b/webapp/pages/groups/_id/_slug.vue
index 2bda97174..96544dd2e 100644
--- a/webapp/pages/groups/_id/_slug.vue
+++ b/webapp/pages/groups/_id/_slug.vue
@@ -55,29 +55,11 @@
-
-
@@ -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"
/>
-
@@ -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() {
diff --git a/webapp/pages/groups/edit/_id/members.vue b/webapp/pages/groups/edit/_id/members.vue
index 9b811d41c..b03521854 100644
--- a/webapp/pages/groups/edit/_id/members.vue
+++ b/webapp/pages/groups/edit/_id/members.vue
@@ -45,6 +45,7 @@ export default {
variables() {
return {
id: this.group.id,
+ first: 999999,
}
},
error(error) {
diff --git a/webapp/yarn.lock b/webapp/yarn.lock
index 826be2e26..9de2d6d6c 100644
--- a/webapp/yarn.lock
+++ b/webapp/yarn.lock
@@ -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"