diff --git a/lib/hooks/ens-cache.js b/lib/hooks/ens-cache.js index d44316670..7a5ea2a23 100644 --- a/lib/hooks/ens-cache.js +++ b/lib/hooks/ens-cache.js @@ -1,178 +1,206 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ENSCacheContext } from '../components/ens-cache-provider.react.js'; import { getETHAddressForUserInfo } from '../shared/account-utils.js'; import { stringForUser } from '../shared/user-utils.js'; +import { getENSNames } from '../utils/ens-helpers.js'; type BaseUserInfo = { +username?: ?string, ... }; -function useENSNames(users: $ReadOnlyArray): T[] { +export type UseENSNamesOptions = { + +allAtOnce?: ?boolean, +}; +function useENSNames( + users: $ReadOnlyArray, + options?: ?UseENSNamesOptions, +): T[] { const cacheContext = React.useContext(ENSCacheContext); const { ensCache } = cacheContext; + const allAtOnce = options?.allAtOnce ?? false; const cachedInfo = React.useMemo( () => users.map(user => { if (!user) { return user; } const ethAddress = getETHAddressForUserInfo(user); const cachedResult = ethAddress && ensCache ? ensCache.getCachedNameForAddress(ethAddress) : null; return { input: user, ethAddress, cachedResult, }; }), [users, ensCache], ); const [fetchedAddresses, setFetchedAddresses] = React.useState< $ReadOnlySet, >(new Set()); const [ensNames, setENSNames] = React.useState<$ReadOnlyMap>( new Map(), ); React.useEffect(() => { if (!ensCache) { return; } - const needFetch = cachedInfo + + const needFetchUsers: $ReadOnlyArray<{ +username: string }> = cachedInfo .map(user => { if (!user) { return null; } const { ethAddress, cachedResult } = user; if (cachedResult || !ethAddress || fetchedAddresses.has(ethAddress)) { return null; } - return ethAddress; + return { username: ethAddress }; }) .filter(Boolean); - if (needFetch.length === 0) { + if (needFetchUsers.length === 0) { return; } + + const needFetchAddresses = needFetchUsers.map(({ username }) => username); setFetchedAddresses(oldFetchedAddresses => { const newFetchedAddresses = new Set(oldFetchedAddresses); - for (const ethAddress of needFetch) { + for (const ethAddress of needFetchAddresses) { newFetchedAddresses.add(ethAddress); } return newFetchedAddresses; }); - for (const ethAddress of needFetch) { + + if (allAtOnce) { + (async () => { + const withENSNames = await getENSNames(ensCache, needFetchUsers); + setENSNames(oldENSNames => { + const newENSNames = new Map(oldENSNames); + for (let i = 0; i < withENSNames.length; i++) { + const ethAddress = needFetchAddresses[i]; + const result = withENSNames[i].username; + newENSNames.set(ethAddress, result); + } + return newENSNames; + }); + })(); + return; + } + + for (const ethAddress of needFetchAddresses) { (async () => { const result = await ensCache.getNameForAddress(ethAddress); if (!result) { return; } setENSNames(oldENSNames => { const newENSNames = new Map(oldENSNames); newENSNames.set(ethAddress, result); return newENSNames; }); })(); } - }, [cachedInfo, fetchedAddresses, ensCache]); + }, [cachedInfo, fetchedAddresses, ensCache, allAtOnce]); return React.useMemo( () => cachedInfo.map(user => { if (!user) { return user; } const { input, ethAddress, cachedResult } = user; if (cachedResult) { return { ...input, username: cachedResult }; } else if (!ethAddress) { return input; } const ensName = ensNames.get(ethAddress); if (ensName) { return { ...input, username: ensName }; } return input; }), [cachedInfo, ensNames], ); } function useENSName(username: string): string { const usersObjArr = React.useMemo(() => [{ username }], [username]); const [potentiallyENSUser] = useENSNames<{ +username: string }>(usersObjArr); return potentiallyENSUser.username; } function useStringForUser( user: ?{ +username?: ?string, +isViewer?: ?boolean, ... }, ): ?string { const toFetch = user?.isViewer ? null : user; // stringForUser ignores username is isViewer, so we skip the ENS fetch const [result] = useENSNames([toFetch]); if (user?.isViewer) { return stringForUser(user); } else if (result) { return stringForUser(result); } else { invariant( !user, 'the only way result can be falsey is if useENSNames is passed a ' + 'falsey input, and that can only happen if useStringForUser input is ' + 'falsey or isViewer is set', ); return user; } } function useENSAvatar(ethAddress: ?string): ?string { const cacheContext = React.useContext(ENSCacheContext); const { ensCache } = cacheContext; const cachedAvatar = React.useMemo(() => { if (!ethAddress) { return ethAddress; } if (!ensCache) { return null; } return ensCache.getCachedAvatarURIForAddress(ethAddress); }, [ensCache, ethAddress]); const [ensAvatars, setENSAvatars] = React.useState< $ReadOnlyMap, >(new Map()); React.useEffect(() => { if (!ensCache || !ethAddress || cachedAvatar !== undefined) { return; } (async () => { const result = await ensCache.getAvatarURIForAddress(ethAddress); if (!result) { return; } setENSAvatars(oldENSAvatars => { const newENSAvatars = new Map(oldENSAvatars); newENSAvatars.set(ethAddress, result); return newENSAvatars; }); })(); }, [ensCache, cachedAvatar, ethAddress]); return React.useMemo(() => { if (!ethAddress) { return ethAddress; } else if (cachedAvatar !== undefined) { return cachedAvatar; } else { return ensAvatars.get(ethAddress); } }, [ethAddress, cachedAvatar, ensAvatars]); } export { useENSNames, useENSName, useStringForUser, useENSAvatar }; diff --git a/lib/selectors/nav-selectors.js b/lib/selectors/nav-selectors.js index e0ab50b04..dbea4c4dc 100644 --- a/lib/selectors/nav-selectors.js +++ b/lib/selectors/nav-selectors.js @@ -1,182 +1,171 @@ // @flow import * as React from 'react'; import { createSelector } from 'reselect'; -import { ENSCacheContext } from '../components/ens-cache-provider.react.js'; +import { useENSNames } from '../hooks/ens-cache.js'; import SearchIndex from '../shared/search-index.js'; import { memberHasAdminPowers } from '../shared/thread-utils.js'; import type { Platform } from '../types/device-types.js'; import { type CalendarQuery, defaultCalendarQuery, } from '../types/entry-types.js'; import type { CalendarFilter } from '../types/filter-types.js'; import type { BaseNavInfo } from '../types/nav-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import type { RawThreadInfo, ThreadInfo } from '../types/thread-types.js'; import { getConfig } from '../utils/config.js'; -import { getENSNames } from '../utils/ens-helpers.js'; import { values } from '../utils/objects.js'; import { useSelector } from '../utils/redux-utils.js'; function timeUntilCalendarRangeExpiration( lastUserInteractionCalendar: number, ): ?number { const inactivityLimit = getConfig().calendarRangeInactivityLimit; if (inactivityLimit === null || inactivityLimit === undefined) { return null; } return lastUserInteractionCalendar + inactivityLimit - Date.now(); } function calendarRangeExpired(lastUserInteractionCalendar: number): boolean { const timeUntil = timeUntilCalendarRangeExpiration( lastUserInteractionCalendar, ); if (timeUntil === null || timeUntil === undefined) { return false; } return timeUntil <= 0; } const currentCalendarQuery: ( state: BaseAppState<>, ) => (calendarActive: boolean) => CalendarQuery = createSelector( (state: BaseAppState<>) => state.entryStore.lastUserInteractionCalendar, (state: BaseAppState<>) => state.navInfo, (state: BaseAppState<>) => state.calendarFilters, ( lastUserInteractionCalendar: number, navInfo: BaseNavInfo, calendarFilters: $ReadOnlyArray, ) => { // Return a function since we depend on the time of evaluation return (calendarActive: boolean, platform: ?Platform): CalendarQuery => { if (calendarActive) { return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters: calendarFilters, }; } if (calendarRangeExpired(lastUserInteractionCalendar)) { return defaultCalendarQuery(platform); } return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters: calendarFilters, }; }; }, ); +// Without allAtOnce, useThreadSearchIndex is very expensive. useENSNames would +// trigger its recalculation for each ENS name as it streams in, but we would +// prefer to trigger its recaculation just once for every update of the +// underlying Redux data. +const useENSNamesOptions = { allAtOnce: true }; + function useThreadSearchIndex( threadInfos: $ReadOnlyArray, ): SearchIndex { const userInfos = useSelector(state => state.userStore.userInfos); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const nonViewerMembers = React.useMemo(() => { const allMembersOfAllThreads = new Map(); for (const threadInfo of threadInfos) { for (const member of threadInfo.members) { const isParentAdmin = memberHasAdminPowers(member); if (!member.role && !isParentAdmin) { continue; } if (member.id === viewerID) { continue; } if (!allMembersOfAllThreads.has(member.id)) { const userInfo = userInfos[member.id]; if (userInfo?.username) { allMembersOfAllThreads.set(member.id, userInfo); } } } } return [...allMembersOfAllThreads.values()]; }, [threadInfos, userInfos, viewerID]); - const cacheContext = React.useContext(ENSCacheContext); - const { ensCache } = cacheContext; - - // We avoid using useENSNames here because the SearchIndex memo below is very - // expensive. useENSNames would trigger its recalculation for each ENS name as - // it streams in, but we would prefer to trigger its recaculation just once - // for every update of the underlying Redux data. - const [nonViewerMembersWithENSNames, setNonViewerMembersWithENSNames] = - React.useState(); - React.useEffect(() => { - if (!ensCache) { - return; - } - (async () => { - const withENSNames = await getENSNames(ensCache, nonViewerMembers); - setNonViewerMembersWithENSNames(withENSNames); - })(); - }, [ensCache, nonViewerMembers]); + const nonViewerMembersWithENSNames = useENSNames( + nonViewerMembers, + useENSNamesOptions, + ); - const resolvedNonViewerMembers = - nonViewerMembersWithENSNames ?? nonViewerMembers; const memberMap = React.useMemo(() => { const result = new Map(); - for (const userInfo of resolvedNonViewerMembers) { + for (const userInfo of nonViewerMembersWithENSNames) { result.set(userInfo.id, userInfo); } return result; - }, [resolvedNonViewerMembers]); + }, [nonViewerMembersWithENSNames]); return React.useMemo(() => { const searchIndex = new SearchIndex(); for (const threadInfo of threadInfos) { const searchTextArray = []; if (threadInfo.name) { searchTextArray.push(threadInfo.name); } if (threadInfo.description) { searchTextArray.push(threadInfo.description); } for (const member of threadInfo.members) { const isParentAdmin = memberHasAdminPowers(member); if (!member.role && !isParentAdmin) { continue; } if (member.id === viewerID) { continue; } const userInfo = userInfos[member.id]; const rawUsername = userInfo?.username; if (rawUsername) { searchTextArray.push(rawUsername); } const resolvedUserInfo = memberMap.get(member.id); const username = resolvedUserInfo?.username; if (username && username !== rawUsername) { searchTextArray.push(username); } } searchIndex.addEntry(threadInfo.id, searchTextArray.join(' ')); } return searchIndex; }, [threadInfos, viewerID, userInfos, memberMap]); } function useGlobalThreadSearchIndex(): SearchIndex { const threadInfos = useSelector(state => state.threadStore.threadInfos); const threadInfosArray = React.useMemo( () => values(threadInfos), [threadInfos], ); return useThreadSearchIndex(threadInfosArray); } export { timeUntilCalendarRangeExpiration, currentCalendarQuery, useThreadSearchIndex, useGlobalThreadSearchIndex, };