diff --git a/lib/components/global-search-index-provider.react.js b/lib/components/global-search-index-provider.react.js new file mode 100644 index 000000000..28129038a --- /dev/null +++ b/lib/components/global-search-index-provider.react.js @@ -0,0 +1,42 @@ +// @flow + +import invariant from 'invariant'; +import * as React from 'react'; + +import { useThreadSearchIndex } from '../selectors/nav-selectors.js'; +import SearchIndex from '../shared/search-index.js'; +import { values } from '../utils/objects.js'; +import { useSelector } from '../utils/redux-utils.js'; + +type GlobalSearchIndexContextType = { + +searchIndex: SearchIndex, +}; + +const GlobalSearchIndexContext: React.Context = + React.createContext(); + +type Props = { + +children: React.Node, +}; +function GlobalSearchIndexProvider(props: Props): React.Node { + const threadInfos = useSelector(state => state.threadStore.threadInfos); + const threadInfosArray = React.useMemo( + () => values(threadInfos), + [threadInfos], + ); + const searchIndex = useThreadSearchIndex(threadInfosArray); + const context = React.useMemo(() => ({ searchIndex }), [searchIndex]); + return ( + + {props.children} + + ); +} + +function useGlobalThreadSearchIndex(): SearchIndex { + const globalSearchIndexContext = React.useContext(GlobalSearchIndexContext); + invariant(globalSearchIndexContext, 'globalSearchIndexContext should be set'); + return globalSearchIndexContext.searchIndex; +} + +export { GlobalSearchIndexProvider, useGlobalThreadSearchIndex }; diff --git a/lib/hooks/child-threads.js b/lib/hooks/child-threads.js index c47da1969..7b10cf2f3 100644 --- a/lib/hooks/child-threads.js +++ b/lib/hooks/child-threads.js @@ -1,136 +1,136 @@ // @flow import * as React from 'react'; import { fetchSingleMostRecentMessagesFromThreadsActionTypes, useFetchSingleMostRecentMessagesFromThreads, } from '../actions/message-actions.js'; +import { useGlobalThreadSearchIndex } from '../components/global-search-index-provider.react.js'; import { type ChatThreadItem, useFilteredChatListData, } from '../selectors/chat-selectors.js'; -import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js'; import { childThreadInfos } from '../selectors/thread-selectors.js'; import { useThreadsInChatList } from '../shared/thread-utils.js'; import threadWatcher from '../shared/thread-watcher.js'; import type { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; type ThreadFilter = { +predicate?: (thread: ThreadInfo) => boolean, +searchText?: string, }; function useFilteredChildThreads( threadID: string, filter?: ThreadFilter, ): $ReadOnlyArray { const defaultPredicate = React.useCallback(() => true, []); const { predicate = defaultPredicate, searchText = '' } = filter ?? {}; const childThreads = useSelector(state => childThreadInfos(state)[threadID]); const subchannelIDs = React.useMemo(() => { if (!childThreads) { return new Set(); } return new Set( childThreads.filter(predicate).map(threadInfo => threadInfo.id), ); }, [childThreads, predicate]); const filterSubchannels = React.useCallback( (thread: ?(RawThreadInfo | ThreadInfo)) => { const candidateThreadID = thread?.id; if (!candidateThreadID) { return false; } return subchannelIDs.has(candidateThreadID); }, [subchannelIDs], ); const allSubchannelsList = useFilteredChatListData(filterSubchannels); const searchIndex = useGlobalThreadSearchIndex(); const searchResultIDs = React.useMemo( () => searchIndex.getSearchResults(searchText), [searchIndex, searchText], ); const searchTextExists = !!searchText.length; const subchannelsThreadInfos = React.useMemo( () => allSubchannelsList.map(item => item.threadInfo), [allSubchannelsList], ); const subchannelsInChatList = useThreadsInChatList(subchannelsThreadInfos); const subchannelIDsInChatList = React.useMemo( () => new Set(subchannelsInChatList.map(thread => thread.id)), [subchannelsInChatList], ); const subchannelIDsNotInChatList = React.useMemo( () => new Set( allSubchannelsList .filter(item => !subchannelIDsInChatList.has(item.threadInfo.id)) .map(item => item.threadInfo.id), ), [allSubchannelsList, subchannelIDsInChatList], ); React.useEffect(() => { if (!subchannelIDsNotInChatList.size) { return undefined; } subchannelIDsNotInChatList.forEach(tID => threadWatcher.watchID(tID)); return () => subchannelIDsNotInChatList.forEach(tID => threadWatcher.removeID(tID)); }, [subchannelIDsNotInChatList]); const filteredSubchannelsChatList = React.useMemo(() => { if (!searchTextExists) { return allSubchannelsList; } return allSubchannelsList.filter(item => searchResultIDs.includes(item.threadInfo.id), ); }, [allSubchannelsList, searchResultIDs, searchTextExists]); const messageStore = useSelector(state => state.messageStore); const threadIDsWithNoMessages = React.useMemo( () => new Set( filteredSubchannelsChatList .map(item => item.threadInfo.id) .filter(id => messageStore.threads[id]?.messageIDs.length === 0), ), [filteredSubchannelsChatList, messageStore], ); const dispatchActionPromise = useDispatchActionPromise(); const fetchSingleMostRecentMessages = useFetchSingleMostRecentMessagesFromThreads(); React.useEffect(() => { if (!threadIDsWithNoMessages.size) { return; } void dispatchActionPromise( fetchSingleMostRecentMessagesFromThreadsActionTypes, fetchSingleMostRecentMessages(Array.from(threadIDsWithNoMessages)), ); }, [ threadIDsWithNoMessages, fetchSingleMostRecentMessages, dispatchActionPromise, ]); return filteredSubchannelsChatList; } export { useFilteredChildThreads }; diff --git a/lib/hooks/thread-search-hooks.js b/lib/hooks/thread-search-hooks.js index 2fe48b931..c53c250f1 100644 --- a/lib/hooks/thread-search-hooks.js +++ b/lib/hooks/thread-search-hooks.js @@ -1,118 +1,118 @@ // @flow import * as React from 'react'; import { useUsersSupportThickThreads } from './user-identities-hooks.js'; import { searchUsers as searchUserCall } from '../actions/user-actions.js'; +import { useGlobalThreadSearchIndex } from '../components/global-search-index-provider.react.js'; import { useLegacyAshoatKeyserverCall } from '../keyserver-conn/legacy-keyserver-call.js'; -import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js'; import { usersWithPersonalThreadSelector } from '../selectors/user-selectors.js'; import { useForwardLookupSearchText, useSearchUsers, } from '../shared/search-utils.js'; import { useOldestPrivateThreadInfo } from '../shared/thread-utils.js'; import type { GlobalAccountUserInfo } from '../types/user-types.js'; import { useSelector } from '../utils/redux-utils.js'; import { usingCommServicesAccessToken } from '../utils/services-utils.js'; export type UserSearchResult = $ReadOnly<{ ...GlobalAccountUserInfo, +supportThickThreads: boolean, }>; type ThreadListSearchResult = { +threadSearchResults: $ReadOnlySet, +usersSearchResults: $ReadOnlyArray, }; const searchUsersOptions = { includeViewer: true }; function useThreadListSearch( searchText: string, viewerID: ?string, ): ThreadListSearchResult { const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); const forwardLookupSearchText = useForwardLookupSearchText(searchText); const oldestPrivateThreadInfo = useOldestPrivateThreadInfo(); const filterAndSetUserResults = React.useCallback( (userInfos: $ReadOnlyArray) => { const usersResults = userInfos.filter( info => !usersWithPersonalThread.has(info.id) && (info.id !== viewerID || !oldestPrivateThreadInfo), ); setUsersSearchResults(usersResults); }, [usersWithPersonalThread, viewerID, oldestPrivateThreadInfo], ); const legacyCallSearchUsers = useLegacyAshoatKeyserverCall(searchUserCall); const legacySearchUsers = React.useCallback( async (usernamePrefix: string) => { if (usernamePrefix.length === 0) { filterAndSetUserResults([]); return; } const { userInfos } = await legacyCallSearchUsers(usernamePrefix); filterAndSetUserResults( userInfos.map(userInfo => ({ ...userInfo, supportThickThreads: false, })), ); }, [filterAndSetUserResults, legacyCallSearchUsers], ); const [threadSearchResults, setThreadSearchResults] = React.useState( new Set(), ); const [usersSearchResults, setUsersSearchResults] = React.useState< $ReadOnlyArray, >([]); const threadSearchIndex = useGlobalThreadSearchIndex(); React.useEffect(() => { void (async () => { const results = threadSearchIndex.getSearchResults(searchText); setThreadSearchResults(new Set(results)); if (!usingCommServicesAccessToken) { await legacySearchUsers(forwardLookupSearchText); } })(); }, [ searchText, forwardLookupSearchText, threadSearchIndex, legacySearchUsers, ]); const usersSupportThickThreads = useUsersSupportThickThreads(); const identitySearchUsers = useSearchUsers( forwardLookupSearchText, searchUsersOptions, ); React.useEffect(() => { void (async () => { if (!usingCommServicesAccessToken) { return; } const userIDsSupportingThickThreads = await usersSupportThickThreads( identitySearchUsers.map(user => user.id), ); filterAndSetUserResults( identitySearchUsers.map(search => ({ ...search, supportThickThreads: userIDsSupportingThickThreads.has(search.id), })), ); })(); }, [filterAndSetUserResults, identitySearchUsers, usersSupportThickThreads]); return { threadSearchResults, usersSearchResults }; } export { useThreadListSearch }; diff --git a/lib/selectors/nav-selectors.js b/lib/selectors/nav-selectors.js index 0a10e97cd..6928e9c6c 100644 --- a/lib/selectors/nav-selectors.js +++ b/lib/selectors/nav-selectors.js @@ -1,210 +1,199 @@ // @flow import * as React from 'react'; import { createSelector } from 'reselect'; import { useENSNames } from '../hooks/ens-cache.js'; import SearchIndex from '../shared/search-index.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 { ThreadInfo, RelativeMemberInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseNavInfo } from '../types/nav-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { threadTypeIsPrivate } from '../types/thread-types-enum.js'; import type { UserInfo } from '../types/user-types.js'; import { getConfig } from '../utils/config.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 and useUserSearchIndex are very // expensive. useENSNames would trigger their recalculation for each ENS name // as it streams in, but we would prefer to trigger their recaculation just // once for every update of the underlying Redux data. const useENSNamesOptions = { allAtOnce: true }; function useUserSearchIndex( userInfos: $ReadOnlyArray, ): SearchIndex { const membersWithENSNames = useENSNames(userInfos, useENSNamesOptions); const memberMap = React.useMemo(() => { const result = new Map(); for (const userInfo of membersWithENSNames) { result.set(userInfo.id, userInfo); } return result; }, [membersWithENSNames]); return React.useMemo(() => { const searchIndex = new SearchIndex(); for (const userInfo of userInfos) { const searchTextArray = []; const rawUsername = userInfo.username; if (rawUsername) { searchTextArray.push(rawUsername); } const resolvedUserInfo = memberMap.get(userInfo.id); const resolvedUsername = resolvedUserInfo?.username; if (resolvedUsername && resolvedUsername !== rawUsername) { searchTextArray.push(resolvedUsername); } searchIndex.addEntry(userInfo.id, searchTextArray.join(' ')); } return searchIndex; }, [userInfos, memberMap]); } 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) { if (!member.role || 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 nonViewerMembersWithENSNames = useENSNames( nonViewerMembers, useENSNamesOptions, ); const memberMap = React.useMemo(() => { const result = new Map(); for (const userInfo of nonViewerMembersWithENSNames) { result.set(userInfo.id, userInfo); } return result; }, [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) { if ( !member.role || (member.id === viewerID && !threadTypeIsPrivate(threadInfo.type)) ) { 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, useUserSearchIndex, useThreadSearchIndex, - useGlobalThreadSearchIndex, }; diff --git a/native/calendar/thread-picker-modal.react.js b/native/calendar/thread-picker-modal.react.js index 5a8f2c190..abf6bf3dd 100644 --- a/native/calendar/thread-picker-modal.react.js +++ b/native/calendar/thread-picker-modal.react.js @@ -1,96 +1,96 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { StyleSheet } from 'react-native'; import { createLocalEntry, createLocalEntryActionType, } from 'lib/actions/entry-actions.js'; -import { useGlobalThreadSearchIndex } from 'lib/selectors/nav-selectors.js'; +import { useGlobalThreadSearchIndex } from 'lib/components/global-search-index-provider.react.js'; import { useOnScreenEntryEditableThreadInfos } from 'lib/shared/thread-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import Modal from '../components/modal.react.js'; import ThreadList from '../components/thread-list.react.js'; import { RootNavigatorContext } from '../navigation/root-navigator-context.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { waitForInteractions } from '../utils/timers.js'; export type ThreadPickerModalParams = { +presentedFrom: string, +dateString: string, }; type Props = { +navigation: RootNavigationProp<'ThreadPickerModal'>, +route: NavigationRoute<'ThreadPickerModal'>, }; function ThreadPickerModal(props: Props): React.Node { const { navigation, route: { params: { dateString }, }, } = props; const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const dispatch = useDispatch(); const rootNavigatorContext = React.useContext(RootNavigatorContext); const threadPicked = React.useCallback( (threadID: string) => { invariant( dateString && viewerID && rootNavigatorContext, 'inputs to threadPicked should be set', ); rootNavigatorContext.setKeyboardHandlingEnabled(false); dispatch({ type: createLocalEntryActionType, payload: createLocalEntry(threadID, dateString, viewerID), }); }, [rootNavigatorContext, dispatch, viewerID, dateString], ); React.useEffect( () => navigation.addListener('blur', async () => { await waitForInteractions(); invariant( rootNavigatorContext, 'RootNavigatorContext should be set in onScreenBlur', ); rootNavigatorContext.setKeyboardHandlingEnabled(true); }), [navigation, rootNavigatorContext], ); const index = useGlobalThreadSearchIndex(); const onScreenThreadInfos = useOnScreenEntryEditableThreadInfos(); return ( ); } const styles = StyleSheet.create({ threadListItem: { paddingLeft: 10, paddingRight: 10, paddingVertical: 2, }, }); export default ThreadPickerModal; diff --git a/native/root.react.js b/native/root.react.js index 905864f15..ccb2d0410 100644 --- a/native/root.react.js +++ b/native/root.react.js @@ -1,442 +1,445 @@ // @flow import { ActionSheetProvider } from '@expo/react-native-action-sheet'; import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import AsyncStorage from '@react-native-async-storage/async-storage'; import type { PossiblyStaleNavigationState, UnsafeContainerActionEvent, GenericNavigationAction, } from '@react-navigation/core'; import { useReduxDevToolsExtension } from '@react-navigation/devtools'; import { NavigationContainer } from '@react-navigation/native'; import * as SplashScreen from 'expo-splash-screen'; import invariant from 'invariant'; import * as React from 'react'; import { StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import Orientation from 'react-native-orientation-locker'; import { SafeAreaProvider, initialWindowMetrics, } from 'react-native-safe-area-context'; import { Provider } from 'react-redux'; import { PersistGate as ReduxPersistGate } from 'redux-persist/es/integration/react.js'; import { ChatMentionContextProvider } from 'lib/components/chat-mention-provider.react.js'; import { EditUserAvatarProvider } from 'lib/components/edit-user-avatar-provider.react.js'; import { ENSCacheProvider } from 'lib/components/ens-cache-provider.react.js'; import { FarcasterDataHandler } from 'lib/components/farcaster-data-handler.react.js'; +import { GlobalSearchIndexProvider } from 'lib/components/global-search-index-provider.react.js'; import IntegrityHandler from 'lib/components/integrity-handler.react.js'; import { MediaCacheProvider } from 'lib/components/media-cache-provider.react.js'; import { NeynarClientProvider } from 'lib/components/neynar-client-provider.react.js'; import PlatformDetailsSynchronizer from 'lib/components/platform-details-synchronizer.react.js'; import PrekeysHandler from 'lib/components/prekeys-handler.react.js'; import { QRAuthProvider } from 'lib/components/qr-auth-provider.react.js'; import { StaffContextProvider } from 'lib/components/staff-provider.react.js'; import SyncCommunityStoreHandler from 'lib/components/sync-community-store-handler.react.js'; import { UserIdentityCacheProvider } from 'lib/components/user-identity-cache.react.js'; import { DBOpsHandler } from 'lib/handlers/db-ops-handler.react.js'; import { HoldersHandler } from 'lib/handlers/holders-handler.react.js'; import { InitialStateSharingHandler } from 'lib/handlers/initial-state-sharing-handler.react.js'; import { TunnelbrokerDeviceTokenHandler } from 'lib/handlers/tunnelbroker-device-token-handler.react.js'; import { UserInfosHandler } from 'lib/handlers/user-infos-handler.react.js'; import { IdentitySearchProvider } from 'lib/identity-search/identity-search-context.js'; import { CallKeyserverEndpointProvider } from 'lib/keyserver-conn/call-keyserver-endpoint-provider.react.js'; import KeyserverConnectionsHandler from 'lib/keyserver-conn/keyserver-connections-handler.js'; import { TunnelbrokerProvider } from 'lib/tunnelbroker/tunnelbroker-context.js'; import { actionLogger } from 'lib/utils/action-logger.js'; import { RegistrationContextProvider } from './account/registration/registration-context-provider.react.js'; import NativeEditThreadAvatarProvider from './avatars/native-edit-thread-avatar-provider.react.js'; import BackupHandler from './backup/backup-handler.js'; import { BottomSheetProvider } from './bottom-sheet/bottom-sheet-provider.react.js'; import ChatContextProvider from './chat/chat-context-provider.react.js'; import MessageEditingContextProvider from './chat/message-editing-context-provider.react.js'; import AccessTokenHandler from './components/access-token-handler.react.js'; import { AutoJoinCommunityHandler } from './components/auto-join-community-handler.react.js'; import BackgroundIdentityLoginHandler from './components/background-identity-login-handler.react.js'; import ConnectFarcasterAlertHandler from './components/connect-farcaster-alert-handler.react.js'; import DMActivityHandler from './components/dm-activity-handler.react.js'; import { FeatureFlagsProvider } from './components/feature-flags-provider.react.js'; import { NUXTipsContextProvider } from './components/nux-tips-context.react.js'; import PersistedStateGate from './components/persisted-state-gate.js'; import ReportHandler from './components/report-handler.react.js'; import VersionSupportedChecker from './components/version-supported.react.js'; import ConnectedStatusBar from './connected-status-bar.react.js'; import { SQLiteDataHandler } from './data/sqlite-data-handler.js'; import ErrorBoundary from './error-boundary.react.js'; import IdentityServiceContextProvider from './identity-service/identity-service-context-provider.react.js'; import InputStateContainer from './input/input-state-container.react.js'; import LifecycleHandler from './lifecycle/lifecycle-handler.react.js'; import MarkdownContextProvider from './markdown/markdown-context-provider.react.js'; import { filesystemMediaCache } from './media/media-cache.js'; import { DeepLinksContextProvider } from './navigation/deep-links-context-provider.react.js'; import { defaultNavigationState } from './navigation/default-state.js'; import { setGlobalNavContext } from './navigation/icky-global.js'; import KeyserverReachabilityHandler from './navigation/keyserver-reachability-handler.js'; import { NavContext, type NavContextType, } from './navigation/navigation-context.js'; import NavigationHandler from './navigation/navigation-handler.react.js'; import { validNavState, isShowingNUXTips, } from './navigation/navigation-utils.js'; import OrientationHandler from './navigation/orientation-handler.react.js'; import { navStateAsyncStorageKey } from './navigation/persistance.js'; import RootNavigator from './navigation/root-navigator.react.js'; import { composeTunnelbrokerQRAuthMessage, handleSecondaryDeviceLogInError, parseTunnelbrokerQRAuthMessage, performBackupRestore, generateQRAuthAESKey, } from './qr-code/qr-code-utils.js'; import ConnectivityUpdater from './redux/connectivity-updater.react.js'; import { DimensionsUpdater } from './redux/dimensions-updater.react.js'; import { getPersistor } from './redux/persist.js'; import { store } from './redux/redux-setup.js'; import { useSelector } from './redux/redux-utils.js'; import { RootContext } from './root-context.js'; import { MessageSearchProvider } from './search/search-provider.react.js'; import Socket from './socket.react.js'; import { useLoadCommFonts } from './themes/fonts.js'; import { DarkTheme, LightTheme } from './themes/navigation.js'; import ThemeHandler from './themes/theme-handler.react.js'; import { alchemyKey, ethersProvider } from './utils/ethers-utils.js'; import { neynarKey } from './utils/neynar-utils.js'; // Add custom items to expo-dev-menu import './dev-menu.js'; import './types/message-types-validator.js'; const navInitAction = Object.freeze({ type: 'NAV/@@INIT' }); const navUnknownAction = Object.freeze({ type: 'NAV/@@UNKNOWN' }); SplashScreen.preventAutoHideAsync().catch(console.log); function Root() { const navStateRef = React.useRef(); const navDispatchRef = React.useRef GenericNavigationAction), ) => void>(); const navStateInitializedRef = React.useRef(false); // We call this here to start the loading process // We gate the UI on the fonts loading in AppNavigator useLoadCommFonts(); const [navContext, setNavContext] = React.useState(null); const updateNavContext = React.useCallback(() => { if ( !navStateRef.current || !navDispatchRef.current || !navStateInitializedRef.current ) { return; } const updatedNavContext = { state: navStateRef.current, dispatch: navDispatchRef.current, }; setNavContext(updatedNavContext); setGlobalNavContext(updatedNavContext); }, []); const [initialState, setInitialState] = React.useState( __DEV__ ? undefined : defaultNavigationState, ); React.useEffect(() => { Orientation.lockToPortrait(); void (async () => { let loadedState = initialState; if (__DEV__) { try { const navStateString = await AsyncStorage.getItem( navStateAsyncStorageKey, ); if (navStateString) { const savedState = JSON.parse(navStateString); if (validNavState(savedState) && !isShowingNUXTips(savedState)) { loadedState = savedState; } } } catch {} } if (!loadedState) { loadedState = defaultNavigationState; } if (loadedState !== initialState) { setInitialState(loadedState); } navStateRef.current = loadedState; updateNavContext(); actionLogger.addOtherAction('navState', navInitAction, null, loadedState); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [updateNavContext]); const setNavStateInitialized = React.useCallback(() => { navStateInitializedRef.current = true; updateNavContext(); }, [updateNavContext]); const [rootContext, setRootContext] = React.useState(() => ({ setNavStateInitialized, })); const detectUnsupervisedBackgroundRef = React.useCallback( (detectUnsupervisedBackground: ?(alreadyClosed: boolean) => boolean) => { setRootContext(prevRootContext => ({ ...prevRootContext, detectUnsupervisedBackground, })); }, [], ); const frozen = useSelector(state => state.frozen); const queuedActionsRef = React.useRef>([]); const onNavigationStateChange = React.useCallback( (state: ?PossiblyStaleNavigationState) => { invariant(state, 'nav state should be non-null'); const prevState = navStateRef.current; navStateRef.current = state; updateNavContext(); const queuedActions = queuedActionsRef.current; queuedActionsRef.current = []; if (queuedActions.length === 0) { queuedActions.push(navUnknownAction); } for (const action of queuedActions) { actionLogger.addOtherAction('navState', action, prevState, state); } if (!__DEV__ || frozen) { return; } void (async () => { try { await AsyncStorage.setItem( navStateAsyncStorageKey, JSON.stringify(state), ); } catch (e) { console.log('AsyncStorage threw while trying to persist navState', e); } })(); }, [updateNavContext, frozen], ); const navContainerRef = React.useRef>(); const containerRef = React.useCallback( (navContainer: ?React.ElementRef) => { navContainerRef.current = navContainer; if (navContainer && !navDispatchRef.current) { navDispatchRef.current = navContainer.dispatch; updateNavContext(); } }, [updateNavContext], ); useReduxDevToolsExtension(navContainerRef); const navContainer = navContainerRef.current; React.useEffect(() => { if (!navContainer) { return undefined; } return navContainer.addListener( '__unsafe_action__', (event: { +data: UnsafeContainerActionEvent, ... }) => { const { action, noop } = event.data; const navState = navStateRef.current; if (noop) { actionLogger.addOtherAction('navState', action, navState, navState); return; } queuedActionsRef.current.push({ ...action, type: `NAV/${action.type}`, }); }, ); }, [navContainer]); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const theme = (() => { if (activeTheme === 'light') { return LightTheme; } else if (activeTheme === 'dark') { return DarkTheme; } return undefined; })(); const gated: React.Node = ( <> ); let navigation; if (initialState) { navigation = ( - - - + + + + + ); } return ( {gated} {navigation} ); } const styles = StyleSheet.create({ app: { flex: 1, }, }); function AppRoot(): React.Node { return ( ); } export default AppRoot; diff --git a/web/app.react.js b/web/app.react.js index 285342ec0..400be3c9b 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,608 +1,611 @@ // @flow import 'basscss/css/basscss.min.css'; import './theme.css'; import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; import classnames from 'classnames'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, } from 'lib/actions/entry-actions.js'; import { ChatMentionContextProvider } from 'lib/components/chat-mention-provider.react.js'; import { EditUserAvatarProvider } from 'lib/components/edit-user-avatar-provider.react.js'; import { FarcasterDataHandler } from 'lib/components/farcaster-data-handler.react.js'; +import { GlobalSearchIndexProvider } from 'lib/components/global-search-index-provider.react.js'; import { ModalProvider, useModalContext, } from 'lib/components/modal-provider.react.js'; import { NeynarClientProvider } from 'lib/components/neynar-client-provider.react.js'; import PlatformDetailsSynchronizer from 'lib/components/platform-details-synchronizer.react.js'; import { QRAuthProvider } from 'lib/components/qr-auth-provider.react.js'; import { StaffContextProvider } from 'lib/components/staff-provider.react.js'; import SyncCommunityStoreHandler from 'lib/components/sync-community-store-handler.react.js'; import { DBOpsHandler } from 'lib/handlers/db-ops-handler.react.js'; import { HoldersHandler } from 'lib/handlers/holders-handler.react.js'; import { TunnelbrokerDeviceTokenHandler } from 'lib/handlers/tunnelbroker-device-token-handler.react.js'; import { UserInfosHandler } from 'lib/handlers/user-infos-handler.react.js'; import { IdentitySearchProvider } from 'lib/identity-search/identity-search-context.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { extractMajorDesktopVersion } from 'lib/shared/version-utils.js'; import type { SecondaryTunnelbrokerConnection } from 'lib/tunnelbroker/secondary-tunnelbroker-connection.js'; import { TunnelbrokerProvider } from 'lib/tunnelbroker/tunnelbroker-context.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { WebNavInfo } from 'lib/types/nav-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import type { MessageToDeviceRequest } from 'lib/types/tunnelbroker/message-to-device-request-types.js'; import { getConfig, registerConfig } from 'lib/utils/config.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { infoFromURL } from 'lib/utils/url-utils.js'; import { AlchemyENSCacheProvider } from 'lib/utils/wagmi-utils.js'; import QRCodeLogin from './account/qr-code-login.react.js'; import AppThemeWrapper from './app-theme-wrapper.react.js'; import { authoritativeKeyserverID } from './authoritative-keyserver.js'; import WebEditThreadAvatarProvider from './avatars/web-edit-thread-avatar-provider.react.js'; import Calendar from './calendar/calendar.react.js'; import Chat from './chat/chat.react.js'; import { EditModalProvider } from './chat/edit-message-provider.js'; import { MemberListSidebarProvider } from './chat/member-list-sidebar/member-list-sidebar-provider.react.js'; import { AutoJoinCommunityHandler } from './components/auto-join-community-handler.react.js'; import CommunitiesRefresher from './components/communities-refresher.react.js'; import DMActivityHandler from './components/dm-activity-handler.react.js'; import LogOutIfMissingCSATHandler from './components/log-out-if-missing-csat-handler.react.js'; import NavigationArrows from './components/navigation-arrows.react.js'; import MinVersionHandler from './components/version-handler.react.js'; import { olmAPI } from './crypto/olm-api.js'; import { sqliteAPI } from './database/sqlite-api.js'; import electron from './electron.js'; import InputStateContainer from './input/input-state-container.react.js'; import InviteLinkHandler from './invite-links/invite-link-handler.react.js'; import InviteLinksRefresher from './invite-links/invite-links-refresher.react.js'; import LoadingIndicator from './loading-indicator.react.js'; import { MenuProvider } from './menu-provider.react.js'; import UpdateModalHandler from './modals/update-modal.react.js'; import SettingsSwitcher from './navigation-panels/settings-switcher.react.js'; import Topbar from './navigation-panels/topbar.react.js'; import BadgeHandler from './push-notif/badge-handler.react.js'; import encryptedNotifUtilsAPI from './push-notif/encrypted-notif-utils-api.js'; import { PushNotificationsHandler } from './push-notif/push-notifs-handler.js'; import { updateNavInfoActionType } from './redux/action-types.js'; import DisconnectedBar from './redux/disconnected-bar.js'; import FocusHandler from './redux/focus-handler.react.js'; import { KeyserverReachabilityHandler } from './redux/keyserver-reachability-handler.js'; import { persistConfig } from './redux/persist.js'; import PolicyAcknowledgmentHandler from './redux/policy-acknowledgment-handler.js'; import { useSelector } from './redux/redux-utils.js'; import VisibilityHandler from './redux/visibility-handler.react.js'; import history from './router-history.js'; import { MessageSearchStateProvider } from './search/message-search-state-provider.react.js'; import AccountSettings from './settings/account-settings.react.js'; import DangerZone from './settings/danger-zone.react.js'; import KeyserverSelectionList from './settings/keyserver-selection-list.react.js'; import { getCommSharedWorker } from './shared-worker/shared-worker-provider.js'; import CommunityPicker from './sidebar/community-picker.react.js'; import Splash from './splash/splash.react.js'; import './typography.css'; import css from './style.css'; import { TooltipProvider } from './tooltips/tooltip-provider.js'; import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils.js'; import { composeTunnelbrokerQRAuthMessage, generateQRAuthAESKey, parseTunnelbrokerQRAuthMessage, useHandleSecondaryDeviceLogInError, } from './utils/qr-code-utils.js'; import { useWebLock, TUNNELBROKER_LOCK_NAME } from './web-lock.js'; // We want Webpack's css-loader and style-loader to handle the Fontawesome CSS, // so we disable the autoAddCss logic and import the CSS file. Otherwise every // icon flashes huge for a second before the CSS is loaded. import '@fortawesome/fontawesome-svg-core/styles.css'; faConfig.autoAddCss = false; const desktopDetails = electron?.version ? { majorDesktopVersion: extractMajorDesktopVersion(electron?.version) } : null; registerConfig({ // We can't securely cache credentials on web resolveKeyserverSessionInvalidationUsingNativeCredentials: null, setSessionIDOnRequest: true, // Never reset the calendar range calendarRangeInactivityLimit: null, platformDetails: { platform: electron?.platform ?? 'web', codeVersion: 143, stateVersion: persistConfig.version, ...desktopDetails, }, authoritativeKeyserverID, olmAPI, sqliteAPI, encryptedNotifUtilsAPI, showAlert: (title: string, message: string) => window.alert(`${title}: ${message}`), }); const versionBroadcast = new BroadcastChannel('comm_version'); versionBroadcast.postMessage(getConfig().platformDetails.codeVersion); versionBroadcast.onmessage = (event: MessageEvent) => { if (event.data && event.data !== getConfig().platformDetails.codeVersion) { location.reload(); } }; // Start initializing the shared worker immediately void getCommSharedWorker(); type BaseProps = { +location: { +pathname: string, ... }, }; type Props = { ...BaseProps, // Redux state +navInfo: WebNavInfo, +entriesLoadingStatus: LoadingStatus, +loggedIn: boolean, +activeThreadCurrentlyUnread: boolean, // Redux dispatch functions +dispatch: Dispatch, +modals: $ReadOnlyArray, }; class App extends React.PureComponent { componentDidMount() { const { navInfo, location: { pathname }, loggedIn, } = this.props; const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (pathname !== newURL) { history.replace(newURL); } } componentDidUpdate(prevProps: Props) { const { navInfo, location: { pathname }, loggedIn, } = this.props; if (!_isEqual(navInfo)(prevProps.navInfo)) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.push(newURL); } } else if (pathname !== prevProps.location.pathname) { const urlInfo = infoFromURL(pathname); const newNavInfo = navInfoFromURL(urlInfo, { navInfo }); if (!_isEqual(newNavInfo)(navInfo)) { this.props.dispatch({ type: updateNavInfoActionType, payload: newNavInfo, }); } } else if (loggedIn !== prevProps.loggedIn) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.replace(newURL); } } if (loggedIn !== prevProps.loggedIn) { electron?.clearHistory(); } } onWordmarkClicked = () => { this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat' }, }); }; render(): React.Node { let content; if (this.props.loggedIn) { content = ( <> {this.renderMainContent()} {this.props.modals} ); } else { content = ( <> {this.renderLoginPage()} {this.props.modals} ); } return ( - - - - - - - - - - - - - - - - - - {content} + + + + + + + + + + + + + + + + + + + {content} + ); } onHeaderDoubleClick = (): void => electron?.doubleClickTopBar(); stopDoubleClickPropagation: ?(SyntheticEvent) => void = electron ? e => e.stopPropagation() : null; renderLoginPage(): React.Node { const { loginMethod } = this.props.navInfo; if (loginMethod === 'qr-code') { return ; } return ; } renderMainContent(): React.Node { const mainContent = this.getMainContentWithSwitcher(); let navigationArrows = null; if (electron) { navigationArrows = ; } const headerClasses = classnames({ [css.header]: true, [css['electron-draggable']]: electron, }); const wordmarkClasses = classnames({ [css.wordmark]: true, [css['electron-non-draggable']]: electron, [css['wordmark-macos']]: electron?.platform === 'macos', }); return (

Comm

{navigationArrows}
{mainContent}
); } getMainContentWithSwitcher(): React.Node { const { tab, settingsSection } = this.props.navInfo; let mainContent: React.Node; if (tab === 'settings') { if (settingsSection === 'account') { mainContent = ; } else if (settingsSection === 'friend-list') { mainContent = null; } else if (settingsSection === 'block-list') { mainContent = null; } else if (settingsSection === 'keyservers') { mainContent = ; } else if (settingsSection === 'build-info') { mainContent = null; } else if (settingsSection === 'danger-zone') { mainContent = ; } return (
{mainContent}
); } if (tab === 'calendar') { mainContent = ; } else if (tab === 'chat') { mainContent = ; } const mainContentClass = classnames( css['main-content-container'], css['main-content-container-column'], ); return (
{mainContent}
); } } const WEB_TUNNELBROKER_CHANNEL = new BroadcastChannel('shared-tunnelbroker'); const WEB_TUNNELBROKER_MESSAGE_TYPES = Object.freeze({ SEND_MESSAGE: 'send-message', MESSAGE_STATUS: 'message-status', }); function useOtherTabsTunnelbrokerConnection(): SecondaryTunnelbrokerConnection { const onSendMessageCallbacks = React.useRef< Set<(MessageToDeviceRequest) => mixed>, >(new Set()); const onMessageStatusCallbacks = React.useRef< Set<(messageID: string, error: ?string) => mixed>, >(new Set()); React.useEffect(() => { const messageHandler = (event: MessageEvent) => { if (typeof event.data !== 'object' || !event.data) { console.log( 'Invalid message received from shared ' + 'tunnelbroker broadcast channel', event.data, ); return; } const data = event.data; if (data.type === WEB_TUNNELBROKER_MESSAGE_TYPES.SEND_MESSAGE) { if (typeof data.message !== 'object' || !data.message) { console.log( 'Invalid tunnelbroker message request received ' + 'from shared tunnelbroker broadcast channel', event.data, ); return; } // We know that the input was already validated const message: MessageToDeviceRequest = (data.message: any); for (const callback of onSendMessageCallbacks.current) { callback(message); } } else if (data.type === WEB_TUNNELBROKER_MESSAGE_TYPES.MESSAGE_STATUS) { if (typeof data.messageID !== 'string') { console.log( 'Missing message id in message status message ' + 'from shared tunnelbroker broadcast channel', ); return; } const messageID = data.messageID; if ( typeof data.error !== 'string' && data.error !== null && data.error !== undefined ) { console.log( 'Invalid error in message status message ' + 'from shared tunnelbroker broadcast channel', data.error, ); return; } const error = data.error; for (const callback of onMessageStatusCallbacks.current) { callback(messageID, error); } } else { console.log( 'Invalid message type ' + 'from shared tunnelbroker broadcast channel', data, ); } }; WEB_TUNNELBROKER_CHANNEL.addEventListener('message', messageHandler); return () => WEB_TUNNELBROKER_CHANNEL.removeEventListener('message', messageHandler); }, [onMessageStatusCallbacks, onSendMessageCallbacks]); return React.useMemo( () => ({ sendMessage: message => WEB_TUNNELBROKER_CHANNEL.postMessage({ type: WEB_TUNNELBROKER_MESSAGE_TYPES.SEND_MESSAGE, message, }), onSendMessage: callback => { onSendMessageCallbacks.current.add(callback); return () => { onSendMessageCallbacks.current.delete(callback); }; }, setMessageStatus: (messageID, error) => { WEB_TUNNELBROKER_CHANNEL.postMessage({ type: WEB_TUNNELBROKER_MESSAGE_TYPES.MESSAGE_STATUS, messageID, error, }); }, onMessageStatus: callback => { onMessageStatusCallbacks.current.add(callback); return () => { onMessageStatusCallbacks.current.delete(callback); }; }, }), [onMessageStatusCallbacks, onSendMessageCallbacks], ); } const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const ConnectedApp: React.ComponentType = React.memo( function ConnectedApp(props) { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const navInfo = useSelector(state => state.navInfo); const fetchEntriesLoadingStatus = useSelector( fetchEntriesLoadingStatusSelector, ); const updateCalendarQueryLoadingStatus = useSelector( updateCalendarQueryLoadingStatusSelector, ); const entriesLoadingStatus = combineLoadingStatuses( fetchEntriesLoadingStatus, updateCalendarQueryLoadingStatus, ); const loggedIn = useSelector(isLoggedIn); const activeThreadCurrentlyUnread = useSelector( state => !activeChatThreadID || !!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread, ); const dispatch = useDispatch(); const modalContext = useModalContext(); const modals = React.useMemo( () => modalContext.modals.map(([modal, key]) => ( {modal} )), [modalContext.modals], ); const { lockStatus, releaseLockOrAbortRequest } = useWebLock( TUNNELBROKER_LOCK_NAME, ); const secondaryTunnelbrokerConnection: SecondaryTunnelbrokerConnection = useOtherTabsTunnelbrokerConnection(); const handleSecondaryDeviceLogInError = useHandleSecondaryDeviceLogInError(); return ( ); }, ); function AppWithProvider(props: BaseProps): React.Node { return ( ); } export default AppWithProvider; diff --git a/web/modals/threads/thread-picker-modal.react.js b/web/modals/threads/thread-picker-modal.react.js index d13aa5339..09adcc0ac 100644 --- a/web/modals/threads/thread-picker-modal.react.js +++ b/web/modals/threads/thread-picker-modal.react.js @@ -1,128 +1,128 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; -import { useGlobalThreadSearchIndex } from 'lib/selectors/nav-selectors.js'; +import { useGlobalThreadSearchIndex } from 'lib/components/global-search-index-provider.react.js'; import { useOnScreenEntryEditableThreadInfos, reorderThreadSearchResults, } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import css from './thread-picker-modal.css'; import ThreadAvatar from '../../avatars/thread-avatar.react.js'; import Button from '../../components/button.react.js'; import Search from '../../components/search.react.js'; import Modal, { type ModalOverridableProps } from '../modal.react.js'; type OptionProps = { +threadInfo: ThreadInfo, +createNewEntry: (threadID: string) => void, +onCloseModal: () => void, }; function ThreadPickerOption(props: OptionProps) { const { threadInfo, createNewEntry, onCloseModal } = props; const onClickThreadOption = React.useCallback(() => { createNewEntry(threadInfo.id); onCloseModal(); }, [threadInfo.id, createNewEntry, onCloseModal]); const { uiName } = useResolvedThreadInfo(threadInfo); return (
); } type Props = { ...ModalOverridableProps, +createNewEntry: (threadID: string) => void, }; function ThreadPickerModal(props: Props): React.Node { const { createNewEntry, ...modalProps } = props; const onScreenThreadInfos = useOnScreenEntryEditableThreadInfos(); const searchIndex = useGlobalThreadSearchIndex(); invariant( onScreenThreadInfos.length > 0, "ThreadPicker can't be open when onScreenThreadInfos is empty", ); const [searchText, setSearchText] = React.useState(''); const [searchResults, setSearchResults] = React.useState< $ReadOnlyArray, >([]); const searchRef = React.useRef(); React.useEffect(() => { searchRef.current?.focus(); }, []); const onChangeSearchText = React.useCallback( (text: string) => { const results = searchIndex.getSearchResults(text); setSearchText(text); const threadInfoResults = reorderThreadSearchResults( onScreenThreadInfos, results, ); setSearchResults(threadInfoResults); }, [searchIndex, onScreenThreadInfos], ); const threads = React.useMemo( () => (searchText ? searchResults : onScreenThreadInfos), [searchText, onScreenThreadInfos, searchResults], ); const threadPickerContent = React.useMemo(() => { const options = threads.map(threadInfo => ( )); if (options.length === 0 && searchText.length > 0) { return (
No results for {searchText}
); } else { return options; } }, [threads, createNewEntry, modalProps.onClose, searchText]); return (
{threadPickerContent}
); } export default ThreadPickerModal;