From d7060d9a6b6e06e69874d15f70b8a4738e15374a Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 17 Jun 2026 15:20:53 -0500 Subject: [PATCH] feat: add search bar for members list in channel details --- .../members/ChannelMemberList.test.tsx | 416 +++++++++++------- .../members/useChannelAllMembers.test.tsx | 234 ---------- .../components/members/ChannelAddMembers.tsx | 14 +- .../members/ChannelAllMembersModal.tsx | 29 +- .../components/members/ChannelMemberList.tsx | 160 ++++++- .../navigation-section/PinnedMessageItem.tsx | 2 +- .../navigation-section/PinnedMessageList.tsx | 2 +- .../ChannelDetails/hooks/members/index.ts | 1 - .../hooks/members/useChannelAllMembers.ts | 131 ------ .../components/UIComponents/SearchInput.tsx | 4 +- .../ChannelMemberListContext.tsx | 72 +++ package/src/contexts/index.ts | 1 + .../src/contexts/themeContext/utils/theme.ts | 16 + package/src/i18n/ar.json | 4 +- package/src/i18n/en.json | 2 + package/src/i18n/es.json | 4 +- package/src/i18n/fr.json | 4 +- package/src/i18n/he.json | 4 +- package/src/i18n/hi.json | 4 +- package/src/i18n/it.json | 4 +- package/src/i18n/ja.json | 4 +- package/src/i18n/ko.json | 4 +- package/src/i18n/nl.json | 4 +- package/src/i18n/pt-br.json | 4 +- package/src/i18n/ru.json | 4 +- package/src/i18n/tr.json | 4 +- 26 files changed, 546 insertions(+), 586 deletions(-) delete mode 100644 package/src/components/ChannelDetails/__tests__/members/useChannelAllMembers.test.tsx delete mode 100644 package/src/components/ChannelDetails/hooks/members/useChannelAllMembers.ts create mode 100644 package/src/contexts/channelMemberListContext/ChannelMemberListContext.tsx diff --git a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberList.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberList.test.tsx index 41a073c100..698bf74d7c 100644 --- a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberList.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberList.test.tsx @@ -1,11 +1,13 @@ import React from 'react'; -import { ActivityIndicator, type FlatListProps as RNFlatListProps, Text } from 'react-native'; -import { act, render } from '@testing-library/react-native'; -import type { Channel, ChannelMemberResponse } from 'stream-chat'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import { StateStore } from 'stream-chat'; +import type { ChannelMemberResponse, SearchSourceState } from 'stream-chat'; -import { ChannelDetailsContextProvider } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; -import { ChatContext } from '../../../../contexts/chatContext/ChatContext'; +import { + ChannelDetailsContextProvider, + type ChannelDetailsContextValue, +} from '../../../../contexts/channelDetailsContext/channelDetailsContext'; import { WithComponents } from '../../../../contexts/componentsContext/ComponentsContext'; import { ThemeProvider } from '../../../../contexts/themeContext/ThemeContext'; import { defaultTheme } from '../../../../contexts/themeContext/utils/theme'; @@ -15,72 +17,141 @@ import { generateUser } from '../../../../mock-builders/generator/user'; import type { ChannelMemberActionsSheetProps } from '../../components/members/ChannelMemberActionsSheet'; import type { ChannelMemberItemProps } from '../../components/members/ChannelMemberItem'; import { ChannelMemberList } from '../../components/members/ChannelMemberList'; -import { useChannelAllMembers } from '../../hooks/members/useChannelAllMembers'; -type FlatListProps = RNFlatListProps; +const mockItemProbe: ChannelMemberItemProps[] = []; +const mockSheetProbe: ChannelMemberActionsSheetProps[] = []; -const mockFlatList = jest.fn((_props: FlatListProps) => null); +const mockChannel = { cid: 'messaging:1' }; +let mockCurrentSearchSource: FakeSearchSource; +const mockProviderProbe: { channel: unknown }[] = []; +const mockNotificationTargetProbe: { hostId?: string; panel?: string }[] = []; +const mockAddNotification = jest.fn(); -jest.mock('../../hooks/members/useChannelAllMembers', () => ({ - useChannelAllMembers: jest.fn(), +jest.mock('../../../Notifications/hooks/useNotificationApi', () => ({ + useNotificationApi: () => ({ addNotification: mockAddNotification }), })); -jest.mock('react-native', () => { - const actual = jest.requireActual('react-native'); - - return new Proxy(actual, { - get(target, prop, receiver) { - if (prop === 'FlatList') { - return (props: FlatListProps) => mockFlatList(props); - } +jest.mock('../../../Notifications/NotificationTargetContext', () => ({ + NotificationTargetProvider: ({ + children, + hostId, + panel, + }: { + children: React.ReactNode; + hostId?: string; + panel?: string; + }) => { + mockNotificationTargetProbe.push({ hostId, panel }); + return children; + }, +})); - return Reflect.get(target, prop, receiver); - }, - }); +jest.mock('../../../Notifications/NotificationList', () => { + const ReactLib = require('react'); + const { View } = require('react-native'); + return { + NotificationList: () => ReactLib.createElement(View, { testID: 'notification-list' }), + }; }); -const channel = { - cid: 'messaging:test', - on: () => ({ unsubscribe: () => undefined }), -} as unknown as Channel; - -type HookResult = ReturnType; +jest.mock('../../../../contexts/channelMemberListContext/ChannelMemberListContext', () => ({ + ChannelMemberListProvider: ({ + channel: providedChannel, + children, + }: { + channel: unknown; + children: React.ReactNode; + }) => { + mockProviderProbe.push({ channel: providedChannel }); + return children; + }, + useChannelMemberListContext: () => ({ + channel: mockChannel, + searchSource: mockCurrentSearchSource, + }), +})); -const baseHookResult = (): HookResult => ({ - hasMore: false, - loading: false, - loadMore: jest.fn(), - results: [], +jest.mock('../../../UIComponents/SearchInput', () => { + const ReactLib = require('react'); + const { Text } = require('react-native'); + return { + SearchInput: ({ onChangeText }: { onChangeText: (t: string) => void }) => + ReactLib.createElement( + ReactLib.Fragment, + null, + ReactLib.createElement( + Text, + { onPress: () => onChangeText('query'), testID: 'search-change' }, + 'change', + ), + ReactLib.createElement( + Text, + { onPress: () => onChangeText(''), testID: 'search-clear' }, + 'clear', + ), + ), + }; }); -const mockHook = (overrides: Partial = {}) => { - const value = { ...baseHookResult(), ...overrides }; - (useChannelAllMembers as jest.Mock).mockReturnValue(value); - return value; +type FakeSearchSource = { + search: jest.Mock; + state: StateStore< + Pick< + SearchSourceState, + 'hasNext' | 'isLoading' | 'items' | 'lastQueryError' | 'searchQuery' + > + > & { partialNext: jest.Mock }; }; -const itemProbeCalls: ChannelMemberItemProps[] = []; -const MemberListItemProbe = (props: ChannelMemberItemProps) => { - itemProbeCalls.push(props); - return {props.member.user?.name}; +const makeSearchSource = ( + overrides: Partial<{ + hasNext: boolean; + isLoading: boolean; + items: ChannelMemberResponse[]; + lastQueryError: Error; + searchQuery: string; + }> = {}, +): FakeSearchSource => { + const state = new StateStore({ + hasNext: overrides.hasNext ?? false, + isLoading: overrides.isLoading ?? false, + items: overrides.items, + searchQuery: overrides.searchQuery ?? '', + ...(overrides.lastQueryError ? { lastQueryError: overrides.lastQueryError } : {}), + }); + // The component calls state.partialNext on search input change; spy on it. + jest.spyOn(state, 'partialNext'); + return { + search: jest.fn(), + state: state as FakeSearchSource['state'], + }; +}; + +const MemberItemProbe = (props: ChannelMemberItemProps) => { + const { Text } = require('react-native'); + mockItemProbe.push(props); + return ( + props.onPress?.(props.member)} testID={`member-${props.member.user?.id}`}> + {props.member.user?.id} + + ); }; -const sheetProbeCalls: ChannelMemberActionsSheetProps[] = []; const MemberActionsSheetProbe = (props: ChannelMemberActionsSheetProps) => { - sheetProbeCalls.push(props); + const { Text } = require('react-native'); + mockSheetProbe.push(props); return {props.member.user?.id ?? ''}; }; -const renderList = ({ - additionalFlatListProps, - currentUserId, - onMemberPress, -}: { - additionalFlatListProps?: Partial; - currentUserId?: string; - onMemberPress?: (member: ChannelMemberResponse) => void; -} = {}) => - render( +const tree = ( + searchSource: FakeSearchSource, + props: { + additionalFlatListProps?: object; + onMemberPress?: (member: ChannelMemberResponse) => void; + } = {}, +) => { + mockCurrentSearchSource = searchSource; + return ( - ({ unsubscribe: () => undefined }), - userID: currentUserId, - }, - } as never + channel: mockChannel, + onMemberPress: props.onMemberPress, + } as unknown as ChannelDetailsContextValue } > - - - - - - + + + + - , + ); - -const latestListProps = () => { - const calls = mockFlatList.mock.calls; - return calls[calls.length - 1]?.[0]; }; describe('ChannelMemberList', () => { beforeEach(() => { - mockFlatList.mockClear(); - itemProbeCalls.length = 0; - sheetProbeCalls.length = 0; - mockHook(); + mockItemProbe.length = 0; + mockSheetProbe.length = 0; + mockProviderProbe.length = 0; + mockNotificationTargetProbe.length = 0; }); afterEach(() => jest.clearAllMocks()); - it('renders the loading skeleton while loading with no results yet', () => { - mockHook({ loading: true, results: [] }); + it('wraps its content in the member list provider for the channel', () => { + render(tree(makeSearchSource())); + + expect(mockProviderProbe).toHaveLength(1); + expect(mockProviderProbe[0].channel).toBe(mockChannel); + }); - const list = renderList(); + it('calls search when the component is created', () => { + const searchSource = makeSearchSource(); + render(tree(searchSource)); - expect(list.getByTestId('member-list-loading-skeleton')).toBeTruthy(); - expect(mockFlatList).not.toHaveBeenCalled(); + expect(searchSource.search).toHaveBeenCalledTimes(1); + expect(searchSource.search).toHaveBeenCalledWith(); }); - it('renders the list (not the skeleton) once results exist even while loading', () => { - mockHook({ - loading: true, - results: [generateMember({ user: generateUser({ id: 'alice' }) })], + it('wires the search input to the search source callbacks', () => { + const searchSource = makeSearchSource({ + items: [generateMember({ user: generateUser({ id: 'alice' }) })], }); + render(tree(searchSource)); + searchSource.search.mockClear(); - const list = renderList(); + fireEvent.press(screen.getByTestId('search-change')); + expect(searchSource.state.partialNext).toHaveBeenCalledWith({ searchQuery: 'query' }); + expect(searchSource.search).toHaveBeenCalledWith('query'); - expect(list.queryByTestId('member-list-loading-skeleton')).toBeNull(); - expect(mockFlatList).toHaveBeenCalled(); + fireEvent.press(screen.getByTestId('search-clear')); + expect(searchSource.search).toHaveBeenCalledWith(''); }); - it('feeds the hook results into the flat list with a stable keyExtractor', () => { + it('renders a row per member and forwards a stable keyExtractor', () => { const alice = generateMember({ user: generateUser({ id: 'alice', name: 'Alice' }) }); const bob = generateMember({ user: generateUser({ id: 'bob', name: 'Bob' }) }); - mockHook({ results: [alice, bob] }); - renderList(); + render(tree(makeSearchSource({ items: [alice, bob] }))); - const props = latestListProps(); - expect((props?.data as ChannelMemberResponse[]).map((m) => m.user?.id)).toEqual([ - 'alice', - 'bob', - ]); - expect(props?.keyExtractor?.(alice, 0)).toBe('alice'); + expect(mockItemProbe.map((p) => p.member.user?.id)).toEqual(['alice', 'bob']); + expect(screen.getByTestId('member-alice')).toBeTruthy(); + expect(screen.getByTestId('channel-member-list').props.keyExtractor(alice, 0)).toBe('alice'); }); - it('wires onEndReached to loadMore (with threshold) only when there is more to load', () => { - const loadMore = jest.fn(); - mockHook({ hasMore: true, loadMore, results: [] }); - - renderList(); + it('shows the loading skeleton and keeps the search bar on the initial load', () => { + render(tree(makeSearchSource({ isLoading: true }))); - const props = latestListProps(); - expect(props?.onEndReachedThreshold).toBe(0.2); - expect(props?.onEndReached).toBe(loadMore); + expect(screen.getByTestId('member-list-loading-skeleton')).toBeTruthy(); + expect(screen.getByTestId('search-change')).toBeTruthy(); }); - it('omits onEndReached when there is no more to load', () => { - mockHook({ hasMore: false, results: [] }); + it('shows the empty search result and keeps the search bar when there are no results', () => { + render(tree(makeSearchSource({ isLoading: false, items: [] }))); - renderList(); + expect(screen.getByTestId('empty-search-result')).toBeTruthy(); + expect(screen.getByText('No members found')).toBeTruthy(); + expect(screen.getByTestId('search-change')).toBeTruthy(); + }); - expect(latestListProps()?.onEndReached).toBeUndefined(); + it('renders the loading-more indicator only while loading with existing results', () => { + render( + tree( + makeSearchSource({ + isLoading: true, + items: [generateMember({ user: generateUser({ id: 'alice' }) })], + }), + ), + ); + expect(screen.UNSAFE_getByType(require('react-native').ActivityIndicator)).toBeTruthy(); }); - it('renders a footer spinner only while loading more (loading with existing results)', () => { - const results = [generateMember({ user: generateUser({ id: 'alice' }) })]; - mockHook({ loading: true, results }); - renderList(); - const footer = latestListProps()?.ListFooterComponent as React.ReactElement; - expect(footer).not.toBeNull(); - expect(footer.type).toBe(ActivityIndicator); + it('loads more via the search source when the list end is reached and there is a next page', () => { + const searchSource = makeSearchSource({ + hasNext: true, + items: [generateMember({ user: generateUser({ id: 'alice' }) })], + }); + render(tree(searchSource)); + searchSource.search.mockClear(); - mockFlatList.mockClear(); - mockHook({ loading: false, results }); - renderList(); - expect(latestListProps()?.ListFooterComponent).toBeNull(); + const list = screen.getByTestId('channel-member-list'); + expect(list.props.onEndReachedThreshold).toBe(0.2); + list.props.onEndReached(); + expect(searchSource.search).toHaveBeenCalledTimes(1); }); - it('forwards additionalFlatListProps to the underlying flat list', () => { - mockHook({ results: [generateMember({ user: generateUser({ id: 'alice' }) })] }); + it('does not load more when there is no next page', () => { + const searchSource = makeSearchSource({ + hasNext: false, + items: [generateMember({ user: generateUser({ id: 'alice' }) })], + }); + render(tree(searchSource)); + searchSource.search.mockClear(); + + screen.getByTestId('channel-member-list').props.onEndReached(); + expect(searchSource.search).not.toHaveBeenCalled(); + }); - renderList({ additionalFlatListProps: { bounces: false, testID: 'custom-member-list' } }); + it('forwards additionalFlatListProps to the underlying list', () => { + render( + tree(makeSearchSource({ items: [generateMember({ user: generateUser({ id: 'alice' }) })] }), { + additionalFlatListProps: { bounces: false, testID: 'custom-list' }, + }), + ); - const props = latestListProps(); - expect(props?.testID).toBe('custom-member-list'); - expect(props?.bounces).toBe(false); + const list = screen.getByTestId('custom-list'); + expect(list.props.bounces).toBe(false); + expect(screen.queryByTestId('channel-member-list')).toBeNull(); }); it('opens the per-member actions sheet on press when no onMemberPress override is provided, and closes it', () => { const bob = generateMember({ user: generateUser({ id: 'bob', name: 'Bob' }) }); - mockHook({ results: [bob] }); - - const list = renderList(); + render(tree(makeSearchSource({ items: [bob] }))); - const { renderItem } = latestListProps() ?? {}; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - render((renderItem as any)({ index: 0, item: bob, separators: {} as never })); - const captured = itemProbeCalls.find((p) => p.member.user?.id === 'bob'); + expect(screen.queryByTestId('member-actions-sheet-probe')).toBeNull(); - expect(list.queryByTestId('member-actions-sheet-probe')).toBeNull(); - act(() => captured?.onPress?.(bob)); - expect(list.getByTestId('member-actions-sheet-probe').props.children).toBe('bob'); + fireEvent.press(screen.getByTestId('member-bob')); + expect(screen.getByTestId('member-actions-sheet-probe').props.children).toBe('bob'); - act(() => sheetProbeCalls[sheetProbeCalls.length - 1]?.onClose?.()); - expect(list.queryByTestId('member-actions-sheet-probe')).toBeNull(); + act(() => mockSheetProbe[mockSheetProbe.length - 1]?.onClose?.()); + expect(screen.queryByTestId('member-actions-sheet-probe')).toBeNull(); }); it('calls onMemberPress instead of opening the sheet when an override is provided', () => { const alice = generateMember({ user: generateUser({ id: 'alice', name: 'Alice' }) }); const onMemberPress = jest.fn(); - mockHook({ results: [alice] }); - - const list = renderList({ onMemberPress }); + render(tree(makeSearchSource({ items: [alice] }), { onMemberPress })); - const { renderItem } = latestListProps() ?? {}; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - render((renderItem as any)({ index: 0, item: alice, separators: {} as never })); - const captured = itemProbeCalls.find((p) => p.member.user?.id === 'alice'); - - act(() => captured?.onPress?.(alice)); + fireEvent.press(screen.getByTestId('member-alice')); expect(onMemberPress).toHaveBeenCalledTimes(1); expect(onMemberPress.mock.calls[0][0].user?.id).toBe('alice'); - expect(list.queryByTestId('member-actions-sheet-probe')).toBeNull(); + expect(screen.queryByTestId('member-actions-sheet-probe')).toBeNull(); + }); + + it('targets the channel-details panel with a channel-scoped notification host', () => { + render(tree(makeSearchSource())); + + expect(mockNotificationTargetProbe).toHaveLength(1); + expect(mockNotificationTargetProbe[0]).toEqual({ + hostId: 'channel-member-list:messaging:1', + panel: 'channel-details', + }); + }); + + it('renders the notification list host', () => { + render(tree(makeSearchSource({ items: [] }))); + + expect(screen.getByTestId('notification-list')).toBeTruthy(); + }); + + it('adds an error notification when the search source reports a query error', () => { + const lastQueryError = new Error('boom'); + render(tree(makeSearchSource({ lastQueryError }))); + + expect(mockAddNotification).toHaveBeenCalledTimes(1); + expect(mockAddNotification).toHaveBeenCalledWith({ + message: 'Failed to load members', + options: { + originalError: lastQueryError, + severity: 'error', + type: 'api:channel:query-members:failed', + }, + origin: { context: { channel: mockChannel }, emitter: 'ChannelMemberList' }, + }); + }); + + it('does not add a notification when there is no query error', () => { + render(tree(makeSearchSource({ items: [] }))); + + expect(mockAddNotification).not.toHaveBeenCalled(); }); }); diff --git a/package/src/components/ChannelDetails/__tests__/members/useChannelAllMembers.test.tsx b/package/src/components/ChannelDetails/__tests__/members/useChannelAllMembers.test.tsx deleted file mode 100644 index 81921cc1d3..0000000000 --- a/package/src/components/ChannelDetails/__tests__/members/useChannelAllMembers.test.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import React from 'react'; - -import { act, renderHook, waitFor } from '@testing-library/react-native'; -import type { Channel, ChannelMemberResponse } from 'stream-chat'; - -import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; -import { generateMember } from '../../../../mock-builders/generator/member'; -import { generateUser } from '../../../../mock-builders/generator/user'; -import { useNotificationApi } from '../../../Notifications/hooks/useNotificationApi'; -import { useChannelAllMembers } from '../../hooks/members/useChannelAllMembers'; - -jest.mock('../../../Notifications/hooks/useNotificationApi', () => ({ - useNotificationApi: jest.fn(() => ({ addNotification: jest.fn() })), -})); - -const t = ((key: string) => key) as never; - -const translationWrapper = ({ children }: { children: React.ReactNode }) => ( - input) as never, userLanguage: 'en' }} - > - {children} - -); - -type QueryMembersMock = jest.Mock< - Promise<{ members: ChannelMemberResponse[] }>, - [unknown, unknown, unknown] ->; - -const buildChannel = ({ - members, - memberCount, - queryMembers, -}: { - members: ChannelMemberResponse[]; - memberCount?: number; - queryMembers?: QueryMembersMock; -}): Channel => - ({ - cid: 'messaging:test', - data: memberCount == null ? {} : { member_count: memberCount }, - on: () => ({ unsubscribe: () => undefined }), - queryMembers: queryMembers ?? jest.fn(), - state: { - members: Object.fromEntries( - members.map((m) => [m.user?.id ?? m.user_id ?? '', m]).filter(([k]) => Boolean(k)), - ), - }, - }) as unknown as Channel; - -const buildMembers = (count: number, prefix = 'u') => - Array.from({ length: count }, (_, i) => - generateMember({ user: generateUser({ id: `${prefix}-${i}`, name: `User ${i}` }) }), - ); - -describe('useChannelAllMembers', () => { - let addNotification: jest.Mock; - - beforeEach(() => { - addNotification = jest.fn(); - (useNotificationApi as jest.Mock).mockReturnValue({ addNotification }); - }); - - describe('local mode', () => { - it('returns local members when member_count matches the loaded count', () => { - const members = buildMembers(3); - const queryMembers: QueryMembersMock = jest.fn(); - const channel = buildChannel({ memberCount: 3, members, queryMembers }); - - const { result } = renderHook(() => useChannelAllMembers({ channel })); - - expect(queryMembers).not.toHaveBeenCalled(); - expect(result.current.results.map((m) => m.user?.id)).toEqual(['u-0', 'u-1', 'u-2']); - expect(result.current.hasMore).toBe(false); - expect(result.current.loading).toBe(false); - }); - - it('treats undefined member_count as fully loaded', () => { - const members = buildMembers(2); - const queryMembers: QueryMembersMock = jest.fn(); - const channel = buildChannel({ members, queryMembers }); - - const { result } = renderHook(() => useChannelAllMembers({ channel })); - - expect(queryMembers).not.toHaveBeenCalled(); - expect(result.current.results).toHaveLength(2); - expect(result.current.hasMore).toBe(false); - }); - - it('loadMore is a no-op in local mode', () => { - const members = buildMembers(1); - const queryMembers: QueryMembersMock = jest.fn(); - const channel = buildChannel({ memberCount: 1, members, queryMembers }); - - const { result } = renderHook(() => useChannelAllMembers({ channel })); - - act(() => result.current.loadMore()); - expect(queryMembers).not.toHaveBeenCalled(); - }); - }); - - describe('paginated mode', () => { - it('fetches the first page on mount and exposes loading state', async () => { - const loaded = buildMembers(25, 'loaded'); - const firstPage = buildMembers(25, 'page1'); - const queryMembers: QueryMembersMock = jest.fn().mockResolvedValue({ members: firstPage }); - const channel = buildChannel({ memberCount: 250, members: loaded, queryMembers }); - - const { result } = renderHook(() => useChannelAllMembers({ channel })); - - expect(result.current.loading).toBe(true); - expect(result.current.hasMore).toBe(true); - - await waitFor(() => expect(queryMembers).toHaveBeenCalledTimes(1)); - expect(queryMembers).toHaveBeenCalledWith({}, { created_at: 1 }, { limit: 25, offset: 0 }); - - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.results).toHaveLength(25); - expect(result.current.results[0]?.user?.id).toBe('page1-0'); - expect(result.current.hasMore).toBe(true); - }); - - it('appends the next page on loadMore with the correct offset and dedupes', async () => { - const firstPage = buildMembers(25, 'page1'); - const overlap = firstPage[firstPage.length - 1]; - const secondPageFresh = buildMembers(10, 'page2'); - const secondPage = overlap ? [overlap, ...secondPageFresh] : secondPageFresh; - const queryMembers: QueryMembersMock = jest - .fn() - .mockResolvedValueOnce({ members: firstPage }) - .mockResolvedValueOnce({ members: secondPage }); - const channel = buildChannel({ - memberCount: 300, - members: buildMembers(25, 'loaded'), - queryMembers, - }); - - const { result } = renderHook(() => useChannelAllMembers({ channel })); - - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.results).toHaveLength(25); - - act(() => result.current.loadMore()); - - await waitFor(() => expect(queryMembers).toHaveBeenCalledTimes(2)); - expect(queryMembers).toHaveBeenNthCalledWith( - 2, - {}, - { created_at: 1 }, - { limit: 25, offset: 25 }, - ); - - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.results).toHaveLength(35); - expect(result.current.hasMore).toBe(false); - }); - - it('marks hasMore=false when the first page is shorter than PAGE_SIZE', async () => { - const firstPage = buildMembers(10, 'page1'); - const queryMembers: QueryMembersMock = jest.fn().mockResolvedValue({ members: firstPage }); - const channel = buildChannel({ - memberCount: 200, - members: buildMembers(25, 'loaded'), - queryMembers, - }); - - const { result } = renderHook(() => useChannelAllMembers({ channel })); - - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.hasMore).toBe(false); - - act(() => result.current.loadMore()); - expect(queryMembers).toHaveBeenCalledTimes(1); - }); - - it('guards against concurrent loadMore calls', async () => { - let resolveSecond: ((value: { members: ChannelMemberResponse[] }) => void) | undefined; - const queryMembers: QueryMembersMock = jest - .fn() - .mockResolvedValueOnce({ members: buildMembers(25, 'page1') }) - .mockReturnValueOnce( - new Promise<{ members: ChannelMemberResponse[] }>((resolve) => { - resolveSecond = resolve; - }), - ); - const channel = buildChannel({ - memberCount: 500, - members: buildMembers(25, 'loaded'), - queryMembers, - }); - - const { result } = renderHook(() => useChannelAllMembers({ channel })); - - await waitFor(() => expect(result.current.loading).toBe(false)); - - act(() => result.current.loadMore()); - await waitFor(() => expect(result.current.loading).toBe(true)); - expect(result.current.results.length).toBeGreaterThan(0); - - act(() => result.current.loadMore()); - act(() => result.current.loadMore()); - - expect(queryMembers).toHaveBeenCalledTimes(2); - - act(() => resolveSecond?.({ members: buildMembers(25, 'page2') })); - await waitFor(() => expect(result.current.loading).toBe(false)); - }); - - it('recovers from a queryMembers rejection and notifies the user', async () => { - const queryMembers: QueryMembersMock = jest.fn().mockRejectedValue(new Error('boom')); - const channel = buildChannel({ - memberCount: 200, - members: buildMembers(25, 'loaded'), - queryMembers, - }); - - const { result } = renderHook(() => useChannelAllMembers({ channel }), { - wrapper: translationWrapper, - }); - - await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.results).toEqual([]); - expect(addNotification).toHaveBeenCalledWith( - expect.objectContaining({ - options: expect.objectContaining({ - severity: 'error', - type: 'api:channel:query-members:failed', - }), - }), - ); - }); - }); -}); diff --git a/package/src/components/ChannelDetails/components/members/ChannelAddMembers.tsx b/package/src/components/ChannelDetails/components/members/ChannelAddMembers.tsx index d2117a1f56..13fb201ec0 100644 --- a/package/src/components/ChannelDetails/components/members/ChannelAddMembers.tsx +++ b/package/src/components/ChannelDetails/components/members/ChannelAddMembers.tsx @@ -8,6 +8,7 @@ import { UserListLoadingSkeleton } from './UserListLoadingSkeleton'; import { useChannelAddMembersContext } from '../../../../contexts/channelAddMembersContext/ChannelAddMembersContext'; import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; import { getNotificationErrorOptions } from '../../../../hooks/actions/useChannelActions'; import { useStateStore } from '../../../../hooks/useStateStore'; @@ -43,6 +44,11 @@ const listStateSelector = (state: SearchSourceState) => { export const ChannelAddMembers = ({ additionalFlatListProps }: ChannelAddMembersProps) => { const { t } = useTranslationContext(); const styles = useStyles(); + const { + theme: { + channelDetails: { addMembers }, + }, + } = useTheme(); const { channel } = useChannelDetailsContext(); const { addNotification } = useNotificationApi(); @@ -103,14 +109,14 @@ export const ChannelAddMembers = ({ additionalFlatListProps }: ChannelAddMembers const loadingMoreIndicator = <>{loading && users && users.length > 0 && }; return ( - + searchSource.search(text)} /> @@ -140,7 +146,7 @@ const useStyles = () => { }, listContent: { flexGrow: 1, - paddingBottom: primitives.spacingXl, + paddingBottom: primitives.spacing3xl, }, }), [], diff --git a/package/src/components/ChannelDetails/components/members/ChannelAllMembersModal.tsx b/package/src/components/ChannelDetails/components/members/ChannelAllMembersModal.tsx index 1d269763ba..3c2eb817c3 100644 --- a/package/src/components/ChannelDetails/components/members/ChannelAllMembersModal.tsx +++ b/package/src/components/ChannelDetails/components/members/ChannelAllMembersModal.tsx @@ -6,8 +6,6 @@ import { useTranslationContext } from '../../../../contexts/translationContext/T import { useChannelMemberCount } from '../../../../hooks'; import { useChannelOwnCapabilities } from '../../../../hooks/useChannelOwnCapabilities'; import { UserAdd } from '../../../../icons/user-add'; -import { NotificationList } from '../../../Notifications/NotificationList'; -import { NotificationTargetProvider } from '../../../Notifications/NotificationTargetContext'; import { Button } from '../../../ui/Button/Button'; import { ChannelDetailsModal } from '../modal/Modal'; import { ModalHeader } from '../modal/ModalHeader'; @@ -20,7 +18,10 @@ export type ChannelAllMembersModalProps = { type ChannelAllMembersModalContentProps = Omit; -const ChannelAllMembersModalBody = ({ +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelAllMembersModalContent = ({ onAddMembersPress, onClose, }: ChannelAllMembersModalContentProps) => { @@ -52,32 +53,10 @@ const ChannelAllMembersModalBody = ({ title={t('{{count}} members', { count: total })} /> - ); }; -/** - * @experimental This component is experimental and is subject to change. - */ -export const ChannelAllMembersModalContent = ({ - onAddMembersPress, - onClose, -}: ChannelAllMembersModalContentProps) => { - const { channel } = useChannelDetailsContext(); - const notificationHostId = channel?.cid ? `channel-member-list:${channel.cid}` : undefined; - - if (!notificationHostId) { - return null; - } - - return ( - - - - ); -}; - /** * @experimental This component is experimental and is subject to change. */ diff --git a/package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx b/package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx index 7122fac470..fbf98f2d2b 100644 --- a/package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx +++ b/package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx @@ -1,16 +1,37 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { ActivityIndicator, FlatList, type FlatListProps } from 'react-native'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, FlatList, type FlatListProps, StyleSheet, View } from 'react-native'; -import type { ChannelMemberResponse } from 'stream-chat'; +import type { ChannelMemberResponse, SearchSourceState } from 'stream-chat'; import { MemberListLoadingSkeleton } from './MemberListLoadingSkeleton'; import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { + ChannelMemberListProvider, + useChannelMemberListContext, +} from '../../../../contexts/channelMemberListContext/ChannelMemberListContext'; import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; -import { useChannelAllMembers } from '../../hooks/members/useChannelAllMembers'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; +import { getNotificationErrorOptions } from '../../../../hooks/actions/useChannelActions'; +import { useStateStore } from '../../../../hooks/useStateStore'; +import { primitives } from '../../../../theme'; +import { useNotificationApi } from '../../../Notifications/hooks/useNotificationApi'; +import { NotificationList } from '../../../Notifications/NotificationList'; +import { NotificationTargetProvider } from '../../../Notifications/NotificationTargetContext'; +import { EmptySearchResult } from '../../../UIComponents/EmptySearchResult'; +import { SearchInput, SearchInputProps } from '../../../UIComponents/SearchInput'; const keyExtractor = (member: ChannelMemberResponse) => member.user?.id ?? member.user_id ?? ''; +const listStateSelector = (state: SearchSourceState) => ({ + error: state.lastQueryError, + hasNext: state.hasNext, + loading: state.isLoading, + members: state.items, + searchQuery: state.searchQuery, +}); + export type ChannelMemberListProps = { /** * Besides the existing default behavior of the members list, you can attach @@ -19,18 +40,54 @@ export type ChannelMemberListProps = { * See https://reactnative.dev/docs/flatlist#props for the full list. */ additionalFlatListProps?: Partial>; + searchInputProps?: SearchInputProps; }; -/** - * Lists all channel members. - * @experimental This component is experimental and is subject to change. - */ -export const ChannelMemberList = ({ additionalFlatListProps }: ChannelMemberListProps = {}) => { - const { channel, onMemberPress } = useChannelDetailsContext(); +const ChannelMemberListContent = ({ + additionalFlatListProps, + searchInputProps, +}: ChannelMemberListProps) => { + const { t } = useTranslationContext(); + const { + theme: { + channelDetails: { memberList }, + }, + } = useTheme(); + const { onMemberPress } = useChannelDetailsContext(); const { ChannelMemberActionsSheet, ChannelMemberItem } = useComponentsContext(); - const { hasMore, loading, loadMore, results } = useChannelAllMembers({ channel }); + const { addNotification } = useNotificationApi(); + + const { channel, searchSource } = useChannelMemberListContext(); + const { error, hasNext, loading, members, searchQuery } = useStateStore( + searchSource.state, + listStateSelector, + ); + const [selectedMember, setSelectedMember] = useState(null); + const initialized = useRef(false); + useEffect(() => { + if (!initialized.current) { + initialized.current = true; + searchSource.search(); + } + }, [searchSource]); + + useEffect(() => { + if (!error) { + return; + } + addNotification({ + message: t('Failed to load members'), + options: { + ...getNotificationErrorOptions(error), + severity: 'error', + type: 'api:channel:query-members:failed', + }, + origin: { context: { channel }, emitter: 'ChannelMemberList' }, + }); + }, [error, addNotification, channel, t]); + const handleMemberActionsClose = useCallback(() => setSelectedMember(null), []); const handleMemberPress = useCallback( @@ -51,24 +108,46 @@ export const ChannelMemberList = ({ additionalFlatListProps }: ChannelMemberList [ChannelMemberItem, handleMemberPress], ); - const ListFooterComponent = useMemo( - () => (loading && results.length > 0 ? : null), - [loading, results.length], - ); + const loadMore = useCallback(() => { + // hasNext is true by default, !!members prevents calling search on initial load + if (hasNext && !!members) { + searchSource.search(); + } + }, [hasNext, members, searchSource]); - if (loading && results.length === 0) { - return ; - } + const emptyState = + loading && !members ? ( + + ) : ( + + ); return ( - <> + + { + searchSource.state.partialNext({ searchQuery: text }); + searchSource.search(text); + }} + value={searchQuery} + {...searchInputProps} + /> 0 ? : null + } + onEndReached={loadMore} onEndReachedThreshold={0.2} renderItem={renderItem} + style={[styles.list, memberList.list]} + testID='channel-member-list' {...additionalFlatListProps} /> {selectedMember ? ( @@ -78,6 +157,41 @@ export const ChannelMemberList = ({ additionalFlatListProps }: ChannelMemberList visible /> ) : null} - + + + ); +}; + +/** + * Lists all channel members with the ability to search them. + * @experimental This component is experimental and is subject to change. + */ +export const ChannelMemberList = (props: ChannelMemberListProps = {}) => { + const { channel } = useChannelDetailsContext(); + const notificationHostId = channel?.cid ? `channel-member-list:${channel.cid}` : undefined; + + if (!notificationHostId) { + return null; + } + + return ( + + + + + ); }; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + list: { + flex: 1, + }, + listContent: { + flexGrow: 1, + paddingBottom: primitives.spacing3xl, + }, +}); diff --git a/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageItem.tsx b/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageItem.tsx index 7712225363..800cfe26a3 100644 --- a/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageItem.tsx +++ b/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageItem.tsx @@ -69,7 +69,7 @@ const useStyles = () => { alignItems: 'center', flexDirection: 'row', gap: primitives.spacingSm, - paddingHorizontal: primitives.spacingSm, + paddingHorizontal: primitives.spacingMd, paddingVertical: primitives.spacingMd, }, content: { diff --git a/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx b/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx index 506906be49..069a050ba1 100644 --- a/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx +++ b/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx @@ -190,7 +190,7 @@ const useStyles = () => { }, listContent: { flexGrow: 1, - paddingBottom: primitives.spacingXl, + paddingBottom: primitives.spacing3xl, }, }), [], diff --git a/package/src/components/ChannelDetails/hooks/members/index.ts b/package/src/components/ChannelDetails/hooks/members/index.ts index 65eac81365..5c76105511 100644 --- a/package/src/components/ChannelDetails/hooks/members/index.ts +++ b/package/src/components/ChannelDetails/hooks/members/index.ts @@ -1,2 +1 @@ -export * from './useChannelAllMembers'; export * from './useMemberRoleLabel'; diff --git a/package/src/components/ChannelDetails/hooks/members/useChannelAllMembers.ts b/package/src/components/ChannelDetails/hooks/members/useChannelAllMembers.ts deleted file mode 100644 index ace9107089..0000000000 --- a/package/src/components/ChannelDetails/hooks/members/useChannelAllMembers.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import type { Channel, ChannelMemberResponse, MemberFilters, MemberSort } from 'stream-chat'; - -import { useTranslationContext } from '../../../../contexts'; -import { getNotificationErrorOptions } from '../../../../hooks/actions/useChannelActions'; -import { useChannelMembersState } from '../../../ChannelList/hooks/useChannelMembersState'; -import { useNotificationApi } from '../../../Notifications/hooks/useNotificationApi'; - -const PAGE_SIZE = 25; - -export type UseChannelAllMembersResult = { - hasMore: boolean; - loading: boolean; - loadMore: () => void; - results: ChannelMemberResponse[]; -}; - -const noop = () => undefined; - -/** - * @experimental This hook is experimental and is subject to change. - */ -export const useChannelAllMembers = ({ - channel, -}: { - channel: Channel; -}): UseChannelAllMembersResult => { - const { addNotification } = useNotificationApi(); - const { t } = useTranslationContext(); - const localMembers = useChannelMembersState(channel); - - // Mode is decided once on mount (per channel). If member_count is unknown or matches - // the locally-loaded count, we return local state and stay reactive to member events. - // Otherwise we switch into paginated mode for the lifetime of the hook. - const [mode] = useState<'local' | 'paginated'>(() => { - const memberCount = channel.data?.member_count; - const loadedCount = Object.keys(channel.state.members).length; - return memberCount == null || loadedCount >= memberCount ? 'local' : 'paginated'; - }); - - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(mode === 'paginated'); - const [hasMore, setHasMore] = useState(mode === 'paginated'); - - const offsetRef = useRef(0); - const requestIdRef = useRef(0); - const inFlightRef = useRef(false); - - const fetchPage = useCallback( - async ({ append }: { append: boolean }) => { - const requestId = ++requestIdRef.current; - inFlightRef.current = true; - setLoading(true); - if (!append) { - offsetRef.current = 0; - setHasMore(true); - } - - try { - const filter: MemberFilters = {}; - const sort: MemberSort = { created_at: 1 }; - const response = await channel.queryMembers(filter, sort, { - limit: PAGE_SIZE, - offset: offsetRef.current, - }); - - if (requestId !== requestIdRef.current) return; - - const fetched = response.members ?? []; - setResults((prev) => { - if (!append) return fetched; - const seen = new Set(prev.map((m) => m.user_id ?? m.user?.id)); - const deduped = fetched.filter((m) => !seen.has(m.user_id ?? m.user?.id)); - return deduped.length ? [...prev, ...deduped] : prev; - }); - offsetRef.current += fetched.length; - if (fetched.length < PAGE_SIZE) { - setHasMore(false); - } - } catch (err) { - if (requestId !== requestIdRef.current) return; - addNotification({ - message: t('Failed to load members'), - options: { - ...getNotificationErrorOptions(err), - severity: 'error', - type: 'api:channel:query-members:failed', - }, - origin: { context: { channel }, emitter: 'ChannelAllMembers' }, - }); - } finally { - if (requestId === requestIdRef.current) { - inFlightRef.current = false; - setLoading(false); - } - } - }, - [addNotification, channel, t], - ); - - const fetchPageRef = useRef(fetchPage); - fetchPageRef.current = fetchPage; - - useEffect(() => { - if (mode !== 'paginated') return; - fetchPageRef.current({ append: false }); - return () => { - requestIdRef.current += 1; - }; - }, [mode]); - - const loadMore = useCallback(() => { - if (mode !== 'paginated') return; - if (inFlightRef.current || !hasMore || loading) return; - fetchPageRef.current({ append: true }); - }, [mode, hasMore, loading]); - - const localResults = useMemo(() => Object.values(localMembers), [localMembers]); - - if (mode === 'local') { - return { - hasMore: false, - loading: false, - loadMore: noop, - results: localResults, - }; - } - - return { hasMore, loading, loadMore, results }; -}; diff --git a/package/src/components/UIComponents/SearchInput.tsx b/package/src/components/UIComponents/SearchInput.tsx index 0f679ad824..b68956301c 100644 --- a/package/src/components/UIComponents/SearchInput.tsx +++ b/package/src/components/UIComponents/SearchInput.tsx @@ -83,9 +83,7 @@ SearchInput.displayName = 'SearchInput{searchInput}'; const styles = StyleSheet.create({ container: { - paddingBottom: primitives.spacingSm, - paddingHorizontal: primitives.spacingMd, - paddingTop: primitives.spacingXs, + padding: primitives.spacingMd, }, input: { borderRadius: primitives.radiusMax, diff --git a/package/src/contexts/channelMemberListContext/ChannelMemberListContext.tsx b/package/src/contexts/channelMemberListContext/ChannelMemberListContext.tsx new file mode 100644 index 0000000000..c3a302e7d5 --- /dev/null +++ b/package/src/contexts/channelMemberListContext/ChannelMemberListContext.tsx @@ -0,0 +1,72 @@ +import React, { PropsWithChildren, useContext, useState } from 'react'; + +import { Channel, ChannelMemberSearchSource } from 'stream-chat'; + +import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; +import { isTestEnvironment } from '../utils/isTestEnvironment'; + +/** + * @experimental This API is experimental and is subject to change. + */ +export type ChannelMemberListContextValue = { + channel: Channel; + searchSource: ChannelMemberSearchSource; +}; + +export const ChannelMemberListContext = React.createContext( + DEFAULT_BASE_CONTEXT_VALUE as ChannelMemberListContextValue, +); + +/** + * @experimental This API is experimental and is subject to change. + */ +export const ChannelMemberListProvider = ({ + channel, + children, +}: PropsWithChildren<{ channel: Channel }>) => { + const [searchSource] = useState(() => { + const source = new ChannelMemberSearchSource( + channel, + { + allowEmptySearchString: true, + pageSize: 25, + resetOnNewSearchQuery: false, + }, + { + initialFilterConfig: { + $or: { + enabled: true, + generate: ({ searchQuery }) => + searchQuery ? { name: { $autocomplete: searchQuery } } : {}, + }, + }, + }, + ); + source.sort = { name: 1 }; + source.activate(); + return source; + }); + + return ( + + {children} + + ); +}; + +/** + * @experimental This API is experimental and is subject to change. + */ +export const useChannelMemberListContext = () => { + const contextValue = useContext( + ChannelMemberListContext, + ) as unknown as ChannelMemberListContextValue; + + if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { + throw new Error( + 'The useChannelMemberListContext hook was called outside of the ChannelMemberListContext provider. Render the member list UI inside a ChannelMemberListProvider.', + ); + } + + return contextValue; +}; diff --git a/package/src/contexts/index.ts b/package/src/contexts/index.ts index c2bf09175d..6344249b01 100644 --- a/package/src/contexts/index.ts +++ b/package/src/contexts/index.ts @@ -34,6 +34,7 @@ export * from './channelAddMembersContext/ChannelAddMembersContext'; export * from './channelEditDetailsContext'; export * from './channelFileAttachmentListContext/ChannelFileAttachmentListContext'; export * from './channelPinnedMessageListContext/ChannelPinnedMessageListContext'; +export * from './channelMemberListContext/ChannelMemberListContext'; export * from './channelMediaListContext/ChannelMediaListContext'; export * from './pollContext'; export * from './liveLocationManagerContext'; diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 978d5058d5..7aed6fa7a0 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -219,6 +219,11 @@ export type Theme = { role: TextStyle; status: TextStyle; }; + memberList: { + container: ViewStyle; + list: ViewStyle; + listContent: ViewStyle; + }; memberActionsSheet: { actionsList: ViewStyle; container: ViewStyle; @@ -236,6 +241,9 @@ export type Theme = { headerTitle: TextStyle; }; addMembers: { + container: ViewStyle; + list: ViewStyle; + listContent: ViewStyle; searchResultItem: { alreadyMemberInfo: ViewStyle; memberLabel: TextStyle; @@ -1326,6 +1334,11 @@ export const defaultTheme: Theme = { role: {}, status: {}, }, + memberList: { + container: {}, + list: {}, + listContent: {}, + }, memberActionsSheet: { actionsList: {}, container: {}, @@ -1343,6 +1356,9 @@ export const defaultTheme: Theme = { headerTitle: {}, }, addMembers: { + container: {}, + list: {}, + listContent: {}, searchResultItem: { alreadyMemberInfo: {}, memberLabel: {}, diff --git a/package/src/i18n/ar.json b/package/src/i18n/ar.json index 0986f13809..e536704036 100644 --- a/package/src/i18n/ar.json +++ b/package/src/i18n/ar.json @@ -463,5 +463,7 @@ "They won't be able to message or call you. You can unblock them later.": "لن يتمكن/تتمكن من مراسلتك أو الاتصال بك. يمكنك إلغاء الحظر لاحقًا.", "Leave": "مغادرة", "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "ستتوقف عن تلقي الرسائل من {{ name }}. يمكنك الانضمام مجددًا في أي وقت.", - "group": "المجموعة" + "group": "المجموعة", + "No members found": "لم يتم العثور على أعضاء", + "a11y/Search members": "البحث عن الأعضاء" } diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index ba6beb1c9e..c5aef6ae48 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -99,6 +99,7 @@ "No chats here yet…": "No chats here yet…", "No files": "No files", "No items exist": "No items exist", + "No members found": "No members found", "No photos or videos": "No photos or videos", "No pinned messages": "No pinned messages", "No threads here yet": "No threads here yet", @@ -356,6 +357,7 @@ "a11y/Select image": "Select image", "a11y/Select video": "Select video", "a11y/Send voice recording": "Send voice recording", + "a11y/Search members": "Search members", "a11y/Search pinned messages": "Search pinned messages", "a11y/Search users to add": "Search users to add", "a11y/Select {{name}}": "Select {{name}}", diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index cd76ae5a3d..83bf0d0443 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -463,5 +463,7 @@ "They won't be able to message or call you. You can unblock them later.": "No podrá enviarte mensajes ni llamarte. Puedes desbloquearlo más tarde.", "Leave": "Salir", "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "Dejarás de recibir mensajes de {{ name }}. Puedes volver a unirte cuando quieras.", - "group": "el grupo" + "group": "el grupo", + "No members found": "No se encontraron miembros", + "a11y/Search members": "Buscar miembros" } diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index 535e948bc3..86b0d5799e 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -463,5 +463,7 @@ "They won't be able to message or call you. You can unblock them later.": "Cette personne ne pourra plus vous envoyer de messages ni vous appeler. Vous pourrez la débloquer plus tard.", "Leave": "Quitter", "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "Vous ne recevrez plus de messages de {{ name }}. Vous pouvez rejoindre à tout moment.", - "group": "ce groupe" + "group": "ce groupe", + "No members found": "Aucun membre trouvé", + "a11y/Search members": "Rechercher des membres" } diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index b9d6cc8615..b839d384c4 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -463,5 +463,7 @@ "They won't be able to message or call you. You can unblock them later.": "הוא/היא לא יוכל/תוכל לשלוח לך הודעות או להתקשר אליך. ניתן לבטל את החסימה מאוחר יותר.", "Leave": "צא/י", "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "תפסיק/תפסיקי לקבל הודעות מ-{{ name }}. ניתן להצטרף בחזרה בכל עת.", - "group": "הקבוצה" + "group": "הקבוצה", + "No members found": "לא נמצאו חברים", + "a11y/Search members": "חיפוש חברים" } diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index 18dd3f6150..f4b9410571 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -463,5 +463,7 @@ "They won't be able to message or call you. You can unblock them later.": "वे आपको संदेश या कॉल नहीं कर पाएंगे। आप उन्हें बाद में अनब्लॉक कर सकते हैं।", "Leave": "छोड़ें", "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "आपको {{ name }} से संदेश मिलना बंद हो जाएंगे। आप कभी भी दोबारा जुड़ सकते हैं।", - "group": "ग्रुप" + "group": "ग्रुप", + "No members found": "कोई सदस्य नहीं मिला", + "a11y/Search members": "सदस्य खोजें" } diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index 1d36d90e7d..39a13522bb 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -463,5 +463,7 @@ "They won't be able to message or call you. You can unblock them later.": "Non potrà inviarti messaggi né chiamarti. Potrai sbloccarlo in seguito.", "Leave": "Esci", "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "Non riceverai più messaggi da {{ name }}. Puoi rientrare in qualsiasi momento.", - "group": "il gruppo" + "group": "il gruppo", + "No members found": "Nessun membro trovato", + "a11y/Search members": "Cerca membri" } diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index a922096586..d7b2ef5b08 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -463,5 +463,7 @@ "They won't be able to message or call you. You can unblock them later.": "メッセージや通話ができなくなります。あとでブロックを解除することもできます。", "Leave": "退出", "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "{{ name }} からのメッセージが届かなくなります。いつでも再参加できます。", - "group": "グループ" + "group": "グループ", + "No members found": "メンバーが見つかりません", + "a11y/Search members": "メンバーを検索" } diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index 9d37ff60c3..f0bd18d3bf 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -463,5 +463,7 @@ "They won't be able to message or call you. You can unblock them later.": "메시지를 보내거나 전화를 걸 수 없게 됩니다. 나중에 차단을 해제할 수 있습니다.", "Leave": "나가기", "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "{{ name }}에서 더 이상 메시지를 받지 않게 됩니다. 언제든지 다시 참여할 수 있습니다.", - "group": "그룹" + "group": "그룹", + "No members found": "멤버를 찾을 수 없습니다", + "a11y/Search members": "멤버 검색" } diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index 06233b7411..34c7d9038a 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -463,5 +463,7 @@ "They won't be able to message or call you. You can unblock them later.": "Deze persoon kan je geen berichten sturen of bellen. Je kunt de blokkering later opheffen.", "Leave": "Verlaten", "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "Je ontvangt geen berichten meer van {{ name }}. Je kunt op elk moment opnieuw deelnemen.", - "group": "de groep" + "group": "de groep", + "No members found": "Geen leden gevonden", + "a11y/Search members": "Zoek leden" } diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index 7b1e221e41..f0001b5c5f 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -463,5 +463,7 @@ "They won't be able to message or call you. You can unblock them later.": "Essa pessoa não poderá enviar mensagens ou ligar para você. Você pode desbloqueá-la mais tarde.", "Leave": "Sair", "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "Você deixará de receber mensagens de {{ name }}. Pode entrar novamente a qualquer momento.", - "group": "o grupo" + "group": "o grupo", + "No members found": "Nenhum membro encontrado", + "a11y/Search members": "Pesquisar membros" } diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index dc52963fb2..897c3daa1d 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -463,5 +463,7 @@ "They won't be able to message or call you. You can unblock them later.": "Он не сможет писать вам и звонить. Вы сможете снять блокировку позже.", "Leave": "Покинуть", "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "Вы перестанете получать сообщения от {{ name }}. Вы можете присоединиться снова в любое время.", - "group": "группы" + "group": "группы", + "No members found": "Участники не найдены", + "a11y/Search members": "Поиск участников" } diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index f467b1f249..35782fd7a6 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -463,5 +463,7 @@ "They won't be able to message or call you. You can unblock them later.": "Size mesaj gönderemez veya sizi arayamaz. Engeli daha sonra kaldırabilirsiniz.", "Leave": "Ayrıl", "You'll stop receiving messages from {{ name }}. You can rejoin anytime.": "{{ name }} kanalından mesaj almayı bırakacaksınız. İstediğiniz zaman tekrar katılabilirsiniz.", - "group": "grup" + "group": "grup", + "No members found": "Üye bulunamadı", + "a11y/Search members": "Üye ara" }