Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lazy-load contacts #3927

Merged
merged 5 commits into from
Jul 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- code: comply with react hook rules #3955
- fix mailto dialog #3976
- "Realtime Webxdc Channels" toggle not reflecting actual setting value #3992
- even faster load of contact lists in "New Chat" and "New Group" #3927

<a id="1_46_1"></a>

Expand Down
37 changes: 8 additions & 29 deletions src/renderer/components/chat/ChatList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { C, T } from '@deltachat/jsonrpc-client'
import AutoSizer from 'react-virtualized-auto-sizer'
import InfiniteLoader from 'react-window-infinite-loader'

import { useContactIds } from '../contact/ContactList'
import { useLazyLoadedContacts } from '../contact/ContactList'
import { useChatListContextMenu } from './ChatListContextMenu'
import { useMessageResults, useChatList } from './ChatListHelpers'
import {
Expand Down Expand Up @@ -627,37 +627,16 @@ function useContactAndMessageLogic(
searchChatId: number | null = null
) {
const accountId = selectedAccountId()
const { contactIds, queryStrIsValidEmail } = useContactIds(0, queryStr)
const messageResultIds = useMessageResults(queryStr, searchChatId)

// Contacts ----------------
const [contactCache, setContactCache] = useState<{
[id: number]: Type.Contact | undefined
}>({})
const [contactLoadState, setContactLoading] = useState<{
[id: number]: undefined | LoadStatus.FETCHING | LoadStatus.LOADED
}>({})

const isContactLoaded: (index: number) => boolean = index =>
!!contactLoadState[contactIds[index]]
const loadContact: (
startIndex: number,
stopIndex: number
) => Promise<void> = async (startIndex, stopIndex) => {
const ids = contactIds.slice(startIndex, stopIndex + 1)

setContactLoading(state => {
ids.forEach(id => (state[id] = LoadStatus.FETCHING))
return state
})

const contacts = await BackendRemote.rpc.getContactsByIds(accountId, ids)
setContactCache(cache => ({ ...cache, ...contacts }))
setContactLoading(state => {
ids.forEach(id => (state[id] = LoadStatus.LOADED))
return state
})
}
const {
contactIds,
contactCache,
loadContacts: loadContact,
isContactLoaded,
queryStrIsValidEmail,
} = useLazyLoadedContacts(0, queryStr)

// Message ----------------
const [messageCache, setMessageCache] = useState<{
Expand Down
163 changes: 66 additions & 97 deletions src/renderer/components/contact/ContactList.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import React, { useState, useEffect, useMemo, useRef } from 'react'
import { ContactListItem } from './ContactListItem'
import { debounce } from 'debounce'
import { useInitEffect } from '../helpers/hooks'
import { debounceWithInit } from '../chat/ChatListHelpers'
import { BackendRemote, Type } from '../../backend-com'
import { selectedAccountId } from '../../ScreenController'

Expand Down Expand Up @@ -62,112 +60,74 @@ export function ContactList(props: {
)
}

export function useContacts(listFlags: number, queryStr: string) {
const [contacts, setContacts] = useState<Type.Contact[]>([])
const accountId = selectedAccountId()

const debouncedGetContacts2 = useMemo(
() =>
debounce((listFlags: number, queryStr: string) => {
BackendRemote.rpc
.getContacts(accountId, listFlags, queryStr)
.then(setContacts)
}, 200),
[accountId]
)
const updateContacts = (queryStr: string) =>
debouncedGetContacts2(listFlags, queryStr)

useInitEffect(() => {
BackendRemote.rpc
.getContacts(accountId, listFlags, queryStr)
.then(setContacts)
})

return [contacts, updateContacts] as [typeof contacts, typeof updateContacts]
}

async function getAndSetContacts(
/**
* Why not just `BackendRemote.rpc.getContacts()`?
* Because if the user has thousands of contacts (which does happen),
* `BackendRemote.rpc.getContacts()` can take a few seconds.
* `BackendRemote.rpc.getContactIds()` is much faster
* Same for rendering all contacts at the same time,
* see {@link ContactList} docsting
* See https://github.com/deltachat/deltachat-desktop/issues/1830#issuecomment-2122549915
*/
export function useLazyLoadedContacts(
listFlags: number,
queryStr: string,
setContacts: (a: Map<number, Type.Contact>) => void
queryStr: string | undefined
) {
const accountId = selectedAccountId()
const contactArrayToMap = (contactArray: Type.Contact[]) => {
return new Map(
contactArray.map((contact: Type.Contact) => {
return [contact.id, contact]
})
)
}
const contactArray = await BackendRemote.rpc.getContacts(
accountId,
const { contactIds, queryStrIsValidEmail, refresh } = useContactIds(
listFlags,
queryStr
)
setContacts(contactArrayToMap(contactArray))
}

export function useContactsMap(listFlags: number, queryStr: string) {
const [contacts, setContacts] = useState<Map<number, Type.Contact>>(new Map())

const debouncedGetContacts = useMemo(
() =>
debounce((listFlags: number, queryStr: string) => {
getAndSetContacts(listFlags, queryStr, setContacts)
}, 200),
[]
)
const updateContacts = (queryStr: string) =>
debouncedGetContacts(listFlags, queryStr)

useInitEffect(() => {
getAndSetContacts(listFlags, queryStr, setContacts)
})

return [contacts, updateContacts] as [typeof contacts, typeof updateContacts]
}

// The queryStr is trimmed for contact search and for checking validity of the string as an Email address
export function useContactsNew(listFlags: number, initialQueryStr: string) {
const [state, setState] = useState<{
contacts: Type.Contact[]
queryStrIsValidEmail: boolean
}>({ contacts: [], queryStrIsValidEmail: false })
const accountId = selectedAccountId()
const enum LoadStatus {
FETCHING = 1,
LOADED = 2,
}

const debouncedGetContacts2 = useMemo(
() =>
debounceWithInit(async (listFlags: number, queryStr: string) => {
const contacts = await BackendRemote.rpc.getContacts(
accountId,
listFlags,
queryStr.trim()
)
const queryStrIsValidEmail = await BackendRemote.rpc.checkEmailValidity(
queryStr.trim()
)
setState({ contacts, queryStrIsValidEmail })
}, 200),
[accountId]
)
// TODO perf: shall we use Map instead of an object?
// Or does it not matter since there is not going to be too many contacts?
const [contactCache, setContactCache] = useState<{
[id: number]: Type.Contact | undefined
}>({})
const [contactLoadState, setContactLoading] = useState<{
[id: number]: undefined | LoadStatus.FETCHING | LoadStatus.LOADED
}>({})

const isContactLoaded: (index: number) => boolean = index =>
!!contactLoadState[contactIds[index]]
const loadContacts: (
startIndex: number,
stopIndex: number
) => Promise<void> = async (startIndex, stopIndex) => {
const ids = contactIds.slice(startIndex, stopIndex + 1)

setContactLoading(state => {
ids.forEach(id => (state[id] = LoadStatus.FETCHING))
return state
})

const contacts = await BackendRemote.rpc.getContactsByIds(accountId, ids)
setContactCache(cache => ({ ...cache, ...contacts }))
setContactLoading(state => {
ids.forEach(id => (state[id] = LoadStatus.LOADED))
return state
})
}

const search = useCallback(
(queryStr: string) => {
debouncedGetContacts2(listFlags, queryStr)
},
[listFlags, debouncedGetContacts2]
)
return {
contactIds,
isContactLoaded,
loadContacts,
contactCache,

useEffect(
() => debouncedGetContacts2(listFlags, initialQueryStr),
[listFlags, initialQueryStr, debouncedGetContacts2]
)
queryStrIsValidEmail,

return [state, search] as [typeof state, typeof search]
/** Useful when e.g. a contact got deleted or added. */
refresh,
}
}

export function useContactIds(listFlags: number, queryStr: string | undefined) {
function useContactIds(listFlags: number, queryStr: string | undefined) {
const accountId = selectedAccountId()
const [state, setState] = useState<{
contactIds: number[]
Expand Down Expand Up @@ -201,5 +161,14 @@ export function useContactIds(listFlags: number, queryStr: string | undefined) {
}
}, [debouncedGetContactsIds, listFlags, queryStr])

return state
const refresh = () => {
debouncedGetContactsIds(listFlags, queryStr)
debouncedGetContactsIds.flush()
}

return {
...state,
/** Useful when e.g. a contact got deleted or added. */
refresh,
}
}
25 changes: 17 additions & 8 deletions src/renderer/components/dialogs/AddMember/AddMemberDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from 'react'
import { useContactSearch } from '../CreateChat'
import React, { useState } from 'react'
import { AddMemberInnerDialog } from './AddMemberInnerDialog'
import { useContactsMap } from '../../contact/ContactList'
import { useLazyLoadedContacts } from '../../contact/ContactList'
import Dialog from '../../Dialog'
import type { T } from '@deltachat/jsonrpc-client'
import type { DialogProps } from '../../../contexts/DialogContext'
Expand All @@ -22,9 +21,14 @@ export function AddMemberDialog({
isBroadcast?: boolean
isVerificationRequired?: boolean
} & DialogProps) {
const [searchContacts, updateSearchContacts] = useContactsMap(listFlags, '')
const [queryStr, onSearchChange, _, refreshContacts] =
useContactSearch(updateSearchContacts)
const [queryStr, setQueryStr] = useState('')
const {
contactIds,
contactCache,
loadContacts,
queryStrIsValidEmail,
refresh: refreshContacts,
} = useLazyLoadedContacts(listFlags, queryStr)
return (
<Dialog canOutsideClickClose={false} fixed onClose={onClose}>
{AddMemberInnerDialog({
Expand All @@ -35,10 +39,15 @@ export function AddMemberDialog({
onCancel: () => {
onClose()
},
onSearchChange,
onSearchChange: e => setQueryStr(e.target.value),
queryStr,
searchContacts,
queryStrIsValidEmail,

contactIds,
contactCache,
loadContacts,
refreshContacts,

groupMembers,
isBroadcast,
isVerificationRequired,
Expand Down
Loading
Loading