diff --git a/app/lib/methods/helpers/announceSearchResultsForAccessibility.ts b/app/lib/methods/helpers/announceSearchResultsForAccessibility.ts new file mode 100644 index 00000000000..7968561e0c7 --- /dev/null +++ b/app/lib/methods/helpers/announceSearchResultsForAccessibility.ts @@ -0,0 +1,13 @@ +import { AccessibilityInfo } from 'react-native'; + +import I18n from '../../../i18n'; + +export const announceSearchResultsForAccessibility = (count: number): void => { + if (count < 1) { + AccessibilityInfo.announceForAccessibility(I18n.t('No_results_found')); + return; + } + + const message = count === 1 ? I18n.t('One_result_found') : I18n.t('Search_Results_found', { count: count.toString() }); + AccessibilityInfo.announceForAccessibility(message); +}; diff --git a/app/lib/methods/helpers/index.ts b/app/lib/methods/helpers/index.ts index c77650e294c..60a9546a068 100644 --- a/app/lib/methods/helpers/index.ts +++ b/app/lib/methods/helpers/index.ts @@ -18,3 +18,4 @@ export * from './image'; export * from './emitter'; export * from './parseJson'; export * from './fileDownload'; +export * from './announceSearchResultsForAccessibility'; diff --git a/app/views/DirectoryView/hooks/useDirectorySearch.ts b/app/views/DirectoryView/hooks/useDirectorySearch.ts new file mode 100644 index 00000000000..1eff11ed0de --- /dev/null +++ b/app/views/DirectoryView/hooks/useDirectorySearch.ts @@ -0,0 +1,100 @@ +import { useEffect, useState } from 'react'; + +import { type IServerRoom } from '../../../definitions'; +import { announceSearchResultsForAccessibility } from '../../../lib/methods/helpers/announceSearchResultsForAccessibility'; +import { useDebounce } from '../../../lib/methods/helpers/debounce'; +import log, { events, logEvent } from '../../../lib/methods/helpers/log'; +import { getDirectory } from '../../../lib/services/restApi'; + +export const useDirectorySearch = (directoryDefaultView: string) => { + 'use memo'; + + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [text, setText] = useState(''); + const [total, setTotal] = useState(-1); + const [globalUsers, setGlobalUsers] = useState(true); + const [type, setType] = useState(directoryDefaultView); + + // useDebounce keeps a ref to the latest callback, so this always reads fresh state + const load = useDebounce(async ({ newSearch = false }: { newSearch?: boolean } = {}) => { + if (!newSearch && (loading || data.length === total)) { + return; + } + + if (newSearch) { + setData([]); + setTotal(-1); + } + setLoading(true); + + try { + const directories = await getDirectory({ + text, + type, + workspace: globalUsers ? 'all' : 'local', + offset: newSearch ? 0 : data.length, + count: 50, + sort: type === 'users' ? { username: 1 } : { usersCount: -1 } + }); + if (directories.success) { + setData(prev => [...(newSearch ? [] : prev), ...(directories.result as IServerRoom[])]); + setTotal(directories.total); + setLoading(false); + // Announce the full total on a fresh search; loadMore pages shouldn't re-announce + if (newSearch) { + announceSearchResultsForAccessibility(directories.total); + } + } else { + setLoading(false); + } + } catch (e) { + log(e); + setLoading(false); + } + }, 200); + + const search = () => load({ newSearch: true }); + const loadMore = () => load({}); + + const onSearchChangeText = (newText: string) => { + setText(newText); + search(); + }; + + const changeType = (newType: string) => { + setType(newType); + + if (newType === 'users') { + logEvent(events.DIRECTORY_SEARCH_USERS); + } else if (newType === 'channels') { + logEvent(events.DIRECTORY_SEARCH_CHANNELS); + } else if (newType === 'teams') { + logEvent(events.DIRECTORY_SEARCH_TEAMS); + } + + search(); + }; + + const toggleWorkspace = () => { + setGlobalUsers(prev => !prev); + search(); + }; + + // Run the initial search when the hook mounts; `search` is stable, so this fires once + useEffect(() => { + search(); + }, []); + + return { + data, + loading, + type, + globalUsers, + search, + loadMore, + onSearchChangeText, + changeType, + toggleWorkspace + }; +}; diff --git a/app/views/DirectoryView/index.tsx b/app/views/DirectoryView/index.tsx index 795f2742e47..b07cec610ba 100644 --- a/app/views/DirectoryView/index.tsx +++ b/app/views/DirectoryView/index.tsx @@ -1,10 +1,10 @@ -import React from 'react'; -import { AccessibilityInfo, FlatList, type ListRenderItem } from 'react-native'; -import { connect } from 'react-redux'; +import React, { useLayoutEffect } from 'react'; +import { FlatList, type ListRenderItem } from 'react-native'; +import { shallowEqual } from 'react-redux'; import { type NativeStackNavigationOptions, type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { type CompositeNavigationProp } from '@react-navigation/native'; -import { hideActionSheetRef, showActionSheetRef } from '../../containers/ActionSheet'; +import { useActionSheet } from '../../containers/ActionSheet'; import { type ChatsStackParamList } from '../../stacks/types'; import { type MasterDetailInsideStackParamList } from '../../stacks/MasterDetailStack/types'; import * as List from '../../containers/List'; @@ -14,67 +14,65 @@ import I18n from '../../i18n'; import SearchBox from '../../containers/SearchBox'; import ActivityIndicator from '../../containers/ActivityIndicator'; import * as HeaderButton from '../../containers/Header/components/HeaderButton'; -import { debounce } from '../../lib/methods/helpers'; -import log, { events, logEvent } from '../../lib/methods/helpers/log'; -import { type TSupportedThemes, withTheme } from '../../theme'; -import { themes } from '../../lib/constants/colors'; -import { getUserSelector } from '../../selectors/login'; +import { useTheme } from '../../theme'; import SafeAreaView from '../../containers/SafeAreaView'; -import { goRoom, type TGoRoomItem } from '../../lib/methods/helpers/goRoom'; -import { type IApplicationState, type IServerRoom, type IUser, SubscriptionType } from '../../definitions'; +import { goRoom as goRoomMethod, type TGoRoomItem } from '../../lib/methods/helpers/goRoom'; +import { type IServerRoom, SubscriptionType } from '../../definitions'; import styles from './styles'; import Options from './Options'; -import { getDirectory, getRoomByTypeAndName } from '../../lib/services/restApi'; +import { getRoomByTypeAndName } from '../../lib/services/restApi'; import { createDirectMessage } from '../../lib/methods/createDirectMessage'; import { getSubscriptionByRoomId } from '../../lib/database/services/Subscription'; +import { useAppSelector } from '../../lib/hooks/useAppSelector'; +import { useDirectorySearch } from './hooks/useDirectorySearch'; interface IDirectoryViewProps { navigation: CompositeNavigationProp< NativeStackNavigationProp, NativeStackNavigationProp >; - baseUrl: string; - isFederationEnabled: boolean; - user: IUser; - theme: TSupportedThemes; - directoryDefaultView: string; - isMasterDetail: boolean; } -interface IDirectoryViewState { - data: IServerRoom[]; - loading: boolean; - text: string; - total: number; - globalUsers: boolean; - type: string; -} +const DirectoryView = ({ navigation }: IDirectoryViewProps): React.ReactElement => { + const { colors } = useTheme(); + const { showActionSheet, hideActionSheet } = useActionSheet(); + + const { isFederationEnabled, directoryDefaultView, isMasterDetail } = useAppSelector( + state => ({ + isFederationEnabled: state.settings.FEDERATION_Enabled as boolean, + directoryDefaultView: state.settings.Accounts_Directory_DefaultView as string, + isMasterDetail: state.app.isMasterDetail + }), + shallowEqual + ); -class DirectoryView extends React.Component { - constructor(props: IDirectoryViewProps) { - super(props); - this.state = { - data: [], - loading: false, - text: '', - total: -1, - globalUsers: true, - type: props.directoryDefaultView + const { data, loading, type, globalUsers, search, loadMore, onSearchChangeText, changeType, toggleWorkspace } = + useDirectorySearch(directoryDefaultView); + + useLayoutEffect(() => { + const showFilters = () => { + showActionSheet({ + children: ( + { + changeType(newType); + hideActionSheet(); + }} + toggleWorkspace={toggleWorkspace} + isFederationEnabled={isFederationEnabled} + /> + ), + enableContentPanningGesture: false + }); }; - this.setHeader(); - } - componentDidMount() { - this.load({}); - } - - setHeader = () => { - const { navigation, isMasterDetail } = this.props; const options: NativeStackNavigationOptions = { title: I18n.t('Directory'), headerRight: () => ( - + ) }; @@ -83,134 +81,40 @@ class DirectoryView extends React.Component { + goRoomMethod({ item, isMasterDetail }); }; - onSearchChangeText = (text: string) => { - this.setState({ text }, this.search); - }; - - load = debounce(async ({ newSearch = false }) => { - if (newSearch) { - this.setState({ data: [], total: -1, loading: false }); - } - - const { - loading, - text, - total, - data: { length } - } = this.state; - if (loading || length === total) { - return; - } - - this.setState({ loading: true }); - - try { - const { type, globalUsers } = this.state; - let { data } = this.state; - // TODO: workaround to fix Fabric batch behavior. It should be fixed when we migrate to function components - if (newSearch) { - data = []; - } - const directories = await getDirectory({ - text, - type, - workspace: globalUsers ? 'all' : 'local', - offset: data.length, - count: 50, - sort: type === 'users' ? { username: 1 } : { usersCount: -1 } - }); - if (directories.success) { - this.setState(prev => ({ - data: [...prev.data, ...(directories.result as IServerRoom[])], - loading: false, - total: directories.total - })); - this.announceSearchResults(directories.count); - } else { - this.setState({ loading: false }); - } - } catch (e) { - log(e); - this.setState({ loading: false }); - } - }, 200); - - search = () => { - this.load({ newSearch: true }); - }; - - announceSearchResults = (count: number) => { - if (!count) { - AccessibilityInfo.announceForAccessibility(I18n.t('No_results_found')); - return; - } - const message = count === 1 ? I18n.t('One_result_found') : I18n.t('Search_Results_found', { count: count.toString() }); - AccessibilityInfo.announceForAccessibility(message); - }; - - changeType = (type: string) => { - this.setState({ type, data: [] }, () => this.search()); - - if (type === 'users') { - logEvent(events.DIRECTORY_SEARCH_USERS); - } else if (type === 'channels') { - logEvent(events.DIRECTORY_SEARCH_CHANNELS); - } else if (type === 'teams') { - logEvent(events.DIRECTORY_SEARCH_TEAMS); - } - hideActionSheetRef(); - }; - - toggleWorkspace = () => { - this.setState( - ({ globalUsers }) => ({ globalUsers: !globalUsers, data: [] }), - () => this.search() - ); - }; - - showFilters = () => { - const { type, globalUsers } = this.state; - const { isFederationEnabled } = this.props; - showActionSheetRef({ - children: ( - - ), - enableContentPanningGesture: false - }); - }; - - goRoom = (item: TGoRoomItem) => { - const { isMasterDetail } = this.props; - goRoom({ item, isMasterDetail }); - }; - - onPressItem = async (item: IServerRoom) => { + const onPressItem = async (item: IServerRoom) => { try { - const { type } = this.state; if (type === 'users') { const result = await createDirectMessage(item.username as string); if (result.success) { - this.goRoom({ rid: result.room._id, name: item.username, t: SubscriptionType.DIRECT }); + goRoom({ rid: result.room._id, name: item.username, t: SubscriptionType.DIRECT }); } return; } const subscription = await getSubscriptionByRoomId(item._id); if (subscription) { - this.goRoom(subscription); + goRoom(subscription); return; } if (['p', 'c'].includes(item.t) && !item.teamMain) { const result = await getRoomByTypeAndName(item.t, item.name || item.fname); if (result) { - this.goRoom({ + goRoom({ rid: item._id, name: item.name, joinCodeRequired: result.joinCodeRequired, @@ -219,7 +123,7 @@ class DirectoryView extends React.Component ( - <> - - - - ); - - renderItem: ListRenderItem = ({ item, index }) => { - const { data, type } = this.state; - const { baseUrl, user, theme } = this.props; - + const renderItem: ListRenderItem = ({ item, index }) => { let style; if (index === data.length - 1) { style = { ...sharedStyles.separatorBottom, - borderColor: themes[theme].strokeLight + borderColor: colors.strokeLight }; } const commonProps = { title: item.name as string, - onPress: () => this.onPressItem(item), - baseUrl, + onPress: () => onPressItem(item), testID: `directory-view-item-${item.name}`, style, - user, - theme, rid: item._id }; @@ -298,35 +189,25 @@ class DirectoryView extends React.Component { - const { data, loading } = this.state; - const { theme } = this.props; - return ( - - item._id} - ListHeaderComponent={this.renderHeader} - renderItem={this.renderItem} - ItemSeparatorComponent={List.Separator} - keyboardShouldPersistTaps='always' - ListFooterComponent={loading ? : null} - onEndReached={() => this.load({})} - /> - - ); - }; -} + return ( + + + -const mapStateToProps = (state: IApplicationState) => ({ - baseUrl: state.server.server, - user: getUserSelector(state), - isFederationEnabled: state.settings.FEDERATION_Enabled as boolean, - directoryDefaultView: state.settings.Accounts_Directory_DefaultView as string, - isMasterDetail: state.app.isMasterDetail -}); + item._id} + renderItem={renderItem} + ItemSeparatorComponent={List.Separator} + keyboardShouldPersistTaps='always' + ListFooterComponent={loading ? : null} + onEndReached={() => loadMore()} + /> + + ); +}; -export default connect(mapStateToProps)(withTheme(DirectoryView)); +export default DirectoryView; diff --git a/app/views/RoomsListView/hooks/useSearch.ts b/app/views/RoomsListView/hooks/useSearch.ts index 97c7a0d2a12..9563db4a576 100644 --- a/app/views/RoomsListView/hooks/useSearch.ts +++ b/app/views/RoomsListView/hooks/useSearch.ts @@ -1,10 +1,9 @@ import { useCallback, useReducer } from 'react'; -import { AccessibilityInfo } from 'react-native'; import { type IRoomItem } from '../../../containers/RoomItem/interfaces'; import { search as searchLib } from '../../../lib/methods/search'; import { useDebounce } from '../../../lib/methods/helpers/debounce'; -import i18n from '../../../i18n'; +import { announceSearchResultsForAccessibility } from '../../../lib/methods/helpers/announceSearchResultsForAccessibility'; interface SearchState { searchEnabled: boolean; @@ -60,16 +59,6 @@ export const useSearch = () => { const [state, dispatch] = useReducer(searchReducer, initialState); - const announceSearchResultsForAccessibility = (count: number) => { - if (count < 1) { - AccessibilityInfo.announceForAccessibility(i18n.t('No_results_found')); - return; - } - - const message = count === 1 ? i18n.t('One_result_found') : i18n.t('Search_Results_found', { count }); - AccessibilityInfo.announceForAccessibility(message); - }; - const search = useDebounce(async (text: string) => { if (!state.searchEnabled) return; dispatch({ type: 'SET_SEARCHING' });