diff --git a/.maestro/tests/room/search-member.yaml b/.maestro/tests/room/search-member.yaml new file mode 100644 index 0000000000..6d6bf982b3 --- /dev/null +++ b/.maestro/tests/room/search-member.yaml @@ -0,0 +1,91 @@ +appId: chat.rocket.reactnative +name: Search Member +onFlowStart: + - runFlow: '../../helpers/setup.yaml' +tags: + - test-13 + +--- +- evalScript: ${output.user = output.utils.createUser()} + +- runFlow: + file: '../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${output.user.username} + PASSWORD: ${output.user.password} + +- runFlow: + file: '../../helpers/navigate-to-room.yaml' + env: + ROOM: 'general' +- tapOn: + id: room-header +- extendedWaitUntil: + visible: + id: 'room-actions-view' + timeout: 60000 +- tapOn: + id: 'room-actions-members' +- extendedWaitUntil: + visible: + id: 'room-members-view-search' + timeout: 60000 + +# should search in all users +- tapOn: + id: room-members-view-search +- inputText: rohit.bansal +- extendedWaitUntil: + visible: + id: 'room-members-view-item-rohit.bansal' + timeout: 60000 + +# use online status and it should use the text filter +- tapOn: + id: room-members-view-filter +- extendedWaitUntil: + visible: + id: 'room-members-view-toggle-status-online' + timeout: 60000 +- tapOn: + id: room-members-view-toggle-status-online +- extendedWaitUntil: + visible: + text: 'No members found' + timeout: 60000 + +# use all status again and it should use text filter +- tapOn: + id: room-members-view-filter +- extendedWaitUntil: + visible: + id: 'room-members-view-toggle-status-all' + timeout: 60000 +- tapOn: + id: room-members-view-toggle-status-all +- extendedWaitUntil: + visible: + id: 'room-members-view-item-rohit.bansal' + timeout: 60000 +- tapOn: + id: clear-text-input + +- evalScript: ${output.secondUser = output.utils.createUser()} + +# should search for new user in all list +- tapOn: + id: room-members-view-search +- inputText: ${output.secondUser.username} +- extendedWaitUntil: + visible: + id: 'room-members-view-item-${output.secondUser.username}' + timeout: 60000 + +# Verify "No members found" message appears correctly when search returns no results +- tapOn: + id: room-members-view-search +- inputText: nonexistentuser12345 +- extendedWaitUntil: + visible: + text: 'No members found' + timeout: 60000 diff --git a/app/views/RoomMembersView/index.tsx b/app/views/RoomMembersView/index.tsx index cacad557b0..a285fe5f14 100644 --- a/app/views/RoomMembersView/index.tsx +++ b/app/views/RoomMembersView/index.tsx @@ -1,5 +1,5 @@ import { type NavigationProp, type RouteProp, useNavigation, useRoute } from '@react-navigation/native'; -import React, { useEffect, useReducer } from 'react'; +import React, { useCallback, useEffect, useReducer, useRef } from 'react'; import { FlatList, Text, View } from 'react-native'; import { shallowEqual } from 'react-redux'; @@ -17,7 +17,7 @@ import { type IGetRoomRoles, type TSubscriptionModel, type TUserModel } from '.. import I18n from '../../i18n'; import { useAppSelector } from '../../lib/hooks/useAppSelector'; import { usePermissions } from '../../lib/hooks/usePermissions'; -import { compareServerVersion, getRoomTitle, isGroupChat } from '../../lib/methods/helpers'; +import { compareServerVersion, getRoomTitle, isGroupChat, useDebounce } from '../../lib/methods/helpers'; import { handleIgnore } from '../../lib/methods/helpers/handleIgnore'; import { showConfirmationAlert } from '../../lib/methods/helpers/info'; import log from '../../lib/methods/helpers/log'; @@ -41,7 +41,6 @@ import { type TRoomType } from './helpers'; import styles from './styles'; -import { sanitizeLikeString } from '../../lib/database/utils'; const PAGE_SIZE = 25; @@ -76,6 +75,8 @@ const RoomMembersView = (): React.ReactElement => { const { params } = useRoute>(); const navigation = useNavigation>(); + const latestSearchRequest = useRef(0); + const { isMasterDetail, serverVersion, useRealName, user, loading } = useAppSelector( state => ({ isMasterDetail: state.app.isMasterDetail, @@ -95,7 +96,7 @@ const RoomMembersView = (): React.ReactElement => { (state: IRoomMembersViewState, newState: Partial) => ({ ...state, ...newState }), { isLoading: false, - allUsers: false, + allUsers: true, filtering: '', members: [], room: params.room || ({} as TSubscriptionModel), @@ -123,38 +124,84 @@ const RoomMembersView = (): React.ReactElement => { useEffect(() => { const subscription = params?.room?.observe && params.room.observe().subscribe(changes => updateState({ room: changes })); - setHeader(false); - fetchMembers(false); + setHeader(true); return () => subscription?.unsubscribe(); }, []); + const fetchRoles = () => { + if (isGroupChat(state.room)) { + return; + } + if ( + muteUserPermission || + setLeaderPermission || + setOwnerPermission || + setModeratorPermission || + removeUserPermission || + editTeamMemberPermission || + viewAllTeamChannelsPermission || + viewAllTeamsPermission + ) { + fetchRoomMembersRoles(state.room.t as any, state.room.rid, updateState); + } + }; + + const fetchMembers = useCallback(async () => { + const { members, isLoading, end, room, filter, page, allUsers } = state; + const { t } = room; + + if (isLoading || end) { + return; + } + + const requestId = ++latestSearchRequest.current; + updateState({ isLoading: true }); + + try { + const membersResult = await getRoomMembers({ + rid: room.rid, + roomType: t, + type: allUsers ? 'all' : 'online', + filter, + skip: PAGE_SIZE * page, + limit: PAGE_SIZE, + allUsers + }); + + if (requestId !== latestSearchRequest.current) { + return; + } + + const existingIds = new Set(members.map(m => m._id)); + const membersResultFiltered = membersResult?.filter((member: TUserModel) => !existingIds.has(member._id)); + + // Safety check: if page is 0, we replace the list entirely + const newMembers = page === 0 ? membersResultFiltered : [...members, ...(membersResultFiltered || [])]; + const isEnd = membersResult?.length < PAGE_SIZE; + + updateState({ + members: newMembers, + isLoading: false, + end: isEnd, + page: page + 1 + }); + } catch (e) { + log(e); + if (requestId === latestSearchRequest.current) { + updateState({ isLoading: false }); + } + } + }, [state.isLoading, state.end, state.room.t, state.filter, state.page, state.allUsers]); + useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { - const { allUsers } = state; - fetchMembers(allUsers); + fetchMembers(); }); return unsubscribe; }, [navigation]); useEffect(() => { - const fetchRoles = () => { - if (isGroupChat(state.room)) { - return; - } - if ( - muteUserPermission || - setLeaderPermission || - setOwnerPermission || - setModeratorPermission || - removeUserPermission || - editTeamMemberPermission || - viewAllTeamChannelsPermission || - viewAllTeamsPermission - ) { - fetchRoomMembersRoles(state.room.t as any, state.room.rid, updateState); - } - }; fetchRoles(); }, [ muteUserPermission, @@ -164,13 +211,35 @@ const RoomMembersView = (): React.ReactElement => { removeUserPermission, editTeamMemberPermission, viewAllTeamChannelsPermission, - viewAllTeamsPermission + viewAllTeamsPermission, + state.room?.rid, + state.room?.t ]); + useEffect(() => { + fetchMembers(); + }, [state.filter, state.allUsers]); + + const debounceFilterChange = useDebounce((text: string) => { + const trimmedFilter = text.trim(); + + if (!trimmedFilter) { + latestSearchRequest.current += 1; + } + + updateState({ + filter: trimmedFilter, + page: 0, + members: [], + end: false, + isLoading: false + }); + }, 500); + const toggleStatus = (status: boolean) => { try { - updateState({ members: [], allUsers: status, end: false }); - fetchMembers(status); + // We only update 'allUsers'. 'filter' remains in state, so the next fetch uses both. + updateState({ members: [], allUsers: status, end: false, page: 0 }); setHeader(status); } catch (e) { log(e); @@ -189,14 +258,14 @@ const RoomMembersView = (): React.ReactElement => { options: [ { title: I18n.t('Online'), - onPress: () => toggleStatus(true), - right: () => , + onPress: () => toggleStatus(false), + right: () => , testID: 'room-members-view-toggle-status-online' }, { title: I18n.t('All'), - onPress: () => toggleStatus(false), - right: () => , + onPress: () => toggleStatus(true), + right: () => , testID: 'room-members-view-toggle-status-all' } ] @@ -348,49 +417,10 @@ const RoomMembersView = (): React.ReactElement => { }); }; - const fetchMembers = async (status: boolean) => { - const { members, isLoading, end, room, filter, page } = state; - const { t } = room; - - if (isLoading || end) { - return; - } - - updateState({ isLoading: true }); - try { - const membersResult = await getRoomMembers({ - rid: room.rid, - roomType: t, - type: !status ? 'all' : 'online', - filter, - skip: PAGE_SIZE * page, - limit: PAGE_SIZE, - allUsers: !status - }); - const end = membersResult?.length < PAGE_SIZE; - const membersResultFiltered = membersResult?.filter((member: TUserModel) => !members.some(m => m._id === member._id)); - updateState({ - members: [...members, ...membersResultFiltered], - isLoading: false, - end, - page: page + 1 - }); - } catch (e) { - log(e); - updateState({ isLoading: false }); - } - }; - - const filter = sanitizeLikeString(state.filter.toLowerCase()) || ''; - const filteredMembers = - state.members && state.members.length > 0 && state.filter - ? state.members.filter(m => m.username.toLowerCase().match(filter) || m.name?.toLowerCase().match(filter)) - : null; - return ( ( { t={state.room.t} abacAttributes={state.room.abacAttributes} /> - updateState({ filter: text.trim() })} testID='room-members-view-search' /> + debounceFilterChange(text)} testID='room-members-view-search' /> } ListFooterComponent={() => (state.isLoading ? : null)} onEndReachedThreshold={0.1} - onEndReached={() => fetchMembers(state.allUsers)} + onEndReached={() => fetchMembers()} ListEmptyComponent={() => state.end ? ( {I18n.t('No_members_found')}