stream-chat-react-native-core 9.4.1-beta.1 → 9.5.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/lib/commonjs/components/ChannelDetails/components/members/ChannelAddMembers.js +7 -4
  2. package/lib/commonjs/components/ChannelDetails/components/members/ChannelAddMembers.js.map +1 -1
  3. package/lib/commonjs/components/ChannelDetails/components/members/ChannelAllMembersModal.js +2 -23
  4. package/lib/commonjs/components/ChannelDetails/components/members/ChannelAllMembersModal.js.map +1 -1
  5. package/lib/commonjs/components/ChannelDetails/components/members/ChannelMemberList.js +124 -22
  6. package/lib/commonjs/components/ChannelDetails/components/members/ChannelMemberList.js.map +1 -1
  7. package/lib/commonjs/components/ChannelDetails/components/navigation-section/PinnedMessageItem.js +1 -1
  8. package/lib/commonjs/components/ChannelDetails/components/navigation-section/PinnedMessageItem.js.map +1 -1
  9. package/lib/commonjs/components/ChannelDetails/components/navigation-section/PinnedMessageList.js +1 -1
  10. package/lib/commonjs/components/ChannelDetails/components/navigation-section/PinnedMessageList.js.map +1 -1
  11. package/lib/commonjs/components/ChannelDetails/hooks/members/index.js +0 -11
  12. package/lib/commonjs/components/ChannelDetails/hooks/members/index.js.map +1 -1
  13. package/lib/commonjs/components/UIComponents/SearchInput.js +1 -3
  14. package/lib/commonjs/components/UIComponents/SearchInput.js.map +1 -1
  15. package/lib/commonjs/contexts/channelMemberListContext/ChannelMemberListContext.js +63 -0
  16. package/lib/commonjs/contexts/channelMemberListContext/ChannelMemberListContext.js.map +1 -0
  17. package/lib/commonjs/contexts/index.js +11 -0
  18. package/lib/commonjs/contexts/index.js.map +1 -1
  19. package/lib/commonjs/contexts/themeContext/utils/theme.js +8 -0
  20. package/lib/commonjs/contexts/themeContext/utils/theme.js.map +1 -1
  21. package/lib/commonjs/i18n/ar.json +3 -1
  22. package/lib/commonjs/i18n/en.json +2 -0
  23. package/lib/commonjs/i18n/es.json +3 -1
  24. package/lib/commonjs/i18n/fr.json +3 -1
  25. package/lib/commonjs/i18n/he.json +3 -1
  26. package/lib/commonjs/i18n/hi.json +3 -1
  27. package/lib/commonjs/i18n/it.json +3 -1
  28. package/lib/commonjs/i18n/ja.json +3 -1
  29. package/lib/commonjs/i18n/ko.json +3 -1
  30. package/lib/commonjs/i18n/nl.json +3 -1
  31. package/lib/commonjs/i18n/pt-br.json +3 -1
  32. package/lib/commonjs/i18n/ru.json +3 -1
  33. package/lib/commonjs/i18n/tr.json +3 -1
  34. package/lib/commonjs/version.json +1 -1
  35. package/lib/module/components/ChannelDetails/components/members/ChannelAddMembers.js +7 -4
  36. package/lib/module/components/ChannelDetails/components/members/ChannelAddMembers.js.map +1 -1
  37. package/lib/module/components/ChannelDetails/components/members/ChannelAllMembersModal.js +2 -23
  38. package/lib/module/components/ChannelDetails/components/members/ChannelAllMembersModal.js.map +1 -1
  39. package/lib/module/components/ChannelDetails/components/members/ChannelMemberList.js +124 -22
  40. package/lib/module/components/ChannelDetails/components/members/ChannelMemberList.js.map +1 -1
  41. package/lib/module/components/ChannelDetails/components/navigation-section/PinnedMessageItem.js +1 -1
  42. package/lib/module/components/ChannelDetails/components/navigation-section/PinnedMessageItem.js.map +1 -1
  43. package/lib/module/components/ChannelDetails/components/navigation-section/PinnedMessageList.js +1 -1
  44. package/lib/module/components/ChannelDetails/components/navigation-section/PinnedMessageList.js.map +1 -1
  45. package/lib/module/components/ChannelDetails/hooks/members/index.js +0 -11
  46. package/lib/module/components/ChannelDetails/hooks/members/index.js.map +1 -1
  47. package/lib/module/components/UIComponents/SearchInput.js +1 -3
  48. package/lib/module/components/UIComponents/SearchInput.js.map +1 -1
  49. package/lib/module/contexts/channelMemberListContext/ChannelMemberListContext.js +63 -0
  50. package/lib/module/contexts/channelMemberListContext/ChannelMemberListContext.js.map +1 -0
  51. package/lib/module/contexts/index.js +11 -0
  52. package/lib/module/contexts/index.js.map +1 -1
  53. package/lib/module/contexts/themeContext/utils/theme.js +8 -0
  54. package/lib/module/contexts/themeContext/utils/theme.js.map +1 -1
  55. package/lib/module/i18n/ar.json +3 -1
  56. package/lib/module/i18n/en.json +2 -0
  57. package/lib/module/i18n/es.json +3 -1
  58. package/lib/module/i18n/fr.json +3 -1
  59. package/lib/module/i18n/he.json +3 -1
  60. package/lib/module/i18n/hi.json +3 -1
  61. package/lib/module/i18n/it.json +3 -1
  62. package/lib/module/i18n/ja.json +3 -1
  63. package/lib/module/i18n/ko.json +3 -1
  64. package/lib/module/i18n/nl.json +3 -1
  65. package/lib/module/i18n/pt-br.json +3 -1
  66. package/lib/module/i18n/ru.json +3 -1
  67. package/lib/module/i18n/tr.json +3 -1
  68. package/lib/module/version.json +1 -1
  69. package/lib/typescript/components/ChannelDetails/components/members/ChannelAddMembers.d.ts.map +1 -1
  70. package/lib/typescript/components/ChannelDetails/components/members/ChannelAllMembersModal.d.ts +1 -1
  71. package/lib/typescript/components/ChannelDetails/components/members/ChannelAllMembersModal.d.ts.map +1 -1
  72. package/lib/typescript/components/ChannelDetails/components/members/ChannelMemberList.d.ts +4 -2
  73. package/lib/typescript/components/ChannelDetails/components/members/ChannelMemberList.d.ts.map +1 -1
  74. package/lib/typescript/components/ChannelDetails/hooks/members/index.d.ts +0 -1
  75. package/lib/typescript/components/ChannelDetails/hooks/members/index.d.ts.map +1 -1
  76. package/lib/typescript/contexts/channelMemberListContext/ChannelMemberListContext.d.ts +21 -0
  77. package/lib/typescript/contexts/channelMemberListContext/ChannelMemberListContext.d.ts.map +1 -0
  78. package/lib/typescript/contexts/componentsContext/defaultComponents.d.ts +1 -1
  79. package/lib/typescript/contexts/index.d.ts +1 -0
  80. package/lib/typescript/contexts/index.d.ts.map +1 -1
  81. package/lib/typescript/contexts/themeContext/ThemeContext.d.ts +8 -0
  82. package/lib/typescript/contexts/themeContext/ThemeContext.d.ts.map +1 -1
  83. package/lib/typescript/contexts/themeContext/utils/theme.d.ts +8 -0
  84. package/lib/typescript/contexts/themeContext/utils/theme.d.ts.map +1 -1
  85. package/lib/typescript/i18n/ar.json +3 -1
  86. package/lib/typescript/i18n/en.json +2 -0
  87. package/lib/typescript/i18n/es.json +3 -1
  88. package/lib/typescript/i18n/fr.json +3 -1
  89. package/lib/typescript/i18n/he.json +3 -1
  90. package/lib/typescript/i18n/hi.json +3 -1
  91. package/lib/typescript/i18n/it.json +3 -1
  92. package/lib/typescript/i18n/ja.json +3 -1
  93. package/lib/typescript/i18n/ko.json +3 -1
  94. package/lib/typescript/i18n/nl.json +3 -1
  95. package/lib/typescript/i18n/pt-br.json +3 -1
  96. package/lib/typescript/i18n/ru.json +3 -1
  97. package/lib/typescript/i18n/tr.json +3 -1
  98. package/lib/typescript/utils/i18n/Streami18n.d.ts +2 -0
  99. package/lib/typescript/utils/i18n/Streami18n.d.ts.map +1 -1
  100. package/package.json +1 -1
  101. package/src/components/ChannelDetails/__tests__/members/ChannelMemberList.test.tsx +265 -151
  102. package/src/components/ChannelDetails/components/members/ChannelAddMembers.tsx +10 -4
  103. package/src/components/ChannelDetails/components/members/ChannelAllMembersModal.tsx +4 -25
  104. package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx +137 -23
  105. package/src/components/ChannelDetails/components/navigation-section/PinnedMessageItem.tsx +1 -1
  106. package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx +1 -1
  107. package/src/components/ChannelDetails/hooks/members/index.ts +0 -1
  108. package/src/components/UIComponents/SearchInput.tsx +1 -3
  109. package/src/contexts/channelMemberListContext/ChannelMemberListContext.tsx +72 -0
  110. package/src/contexts/index.ts +1 -0
  111. package/src/contexts/themeContext/utils/theme.ts +16 -0
  112. package/src/i18n/ar.json +3 -1
  113. package/src/i18n/en.json +2 -0
  114. package/src/i18n/es.json +3 -1
  115. package/src/i18n/fr.json +3 -1
  116. package/src/i18n/he.json +3 -1
  117. package/src/i18n/hi.json +3 -1
  118. package/src/i18n/it.json +3 -1
  119. package/src/i18n/ja.json +3 -1
  120. package/src/i18n/ko.json +3 -1
  121. package/src/i18n/nl.json +3 -1
  122. package/src/i18n/pt-br.json +3 -1
  123. package/src/i18n/ru.json +3 -1
  124. package/src/i18n/tr.json +3 -1
  125. package/src/version.json +1 -1
  126. package/lib/commonjs/components/ChannelDetails/hooks/members/useChannelAllMembers.js +0 -139
  127. package/lib/commonjs/components/ChannelDetails/hooks/members/useChannelAllMembers.js.map +0 -1
  128. package/lib/module/components/ChannelDetails/hooks/members/useChannelAllMembers.js +0 -139
  129. package/lib/module/components/ChannelDetails/hooks/members/useChannelAllMembers.js.map +0 -1
  130. package/lib/typescript/components/ChannelDetails/hooks/members/useChannelAllMembers.d.ts +0 -14
  131. package/lib/typescript/components/ChannelDetails/hooks/members/useChannelAllMembers.d.ts.map +0 -1
  132. package/src/components/ChannelDetails/__tests__/members/useChannelAllMembers.test.tsx +0 -234
  133. package/src/components/ChannelDetails/hooks/members/useChannelAllMembers.ts +0 -131
@@ -1,11 +1,13 @@
1
1
  import React from 'react';
2
- import { ActivityIndicator, type FlatListProps as RNFlatListProps, Text } from 'react-native';
3
2
 
4
- import { act, render } from '@testing-library/react-native';
5
- import type { Channel, ChannelMemberResponse } from 'stream-chat';
3
+ import { act, fireEvent, render, screen } from '@testing-library/react-native';
4
+ import { StateStore } from 'stream-chat';
5
+ import type { ChannelMemberResponse, SearchSourceState } from 'stream-chat';
6
6
 
7
- import { ChannelDetailsContextProvider } from '../../../../contexts/channelDetailsContext/channelDetailsContext';
8
- import { ChatContext } from '../../../../contexts/chatContext/ChatContext';
7
+ import {
8
+ ChannelDetailsContextProvider,
9
+ type ChannelDetailsContextValue,
10
+ } from '../../../../contexts/channelDetailsContext/channelDetailsContext';
9
11
  import { WithComponents } from '../../../../contexts/componentsContext/ComponentsContext';
10
12
  import { ThemeProvider } from '../../../../contexts/themeContext/ThemeContext';
11
13
  import { defaultTheme } from '../../../../contexts/themeContext/utils/theme';
@@ -15,72 +17,141 @@ import { generateUser } from '../../../../mock-builders/generator/user';
15
17
  import type { ChannelMemberActionsSheetProps } from '../../components/members/ChannelMemberActionsSheet';
16
18
  import type { ChannelMemberItemProps } from '../../components/members/ChannelMemberItem';
17
19
  import { ChannelMemberList } from '../../components/members/ChannelMemberList';
18
- import { useChannelAllMembers } from '../../hooks/members/useChannelAllMembers';
19
20
 
20
- type FlatListProps = RNFlatListProps<ChannelMemberResponse>;
21
+ const mockItemProbe: ChannelMemberItemProps[] = [];
22
+ const mockSheetProbe: ChannelMemberActionsSheetProps[] = [];
21
23
 
22
- const mockFlatList = jest.fn((_props: FlatListProps) => null);
24
+ const mockChannel = { cid: 'messaging:1' };
25
+ let mockCurrentSearchSource: FakeSearchSource;
26
+ const mockProviderProbe: { channel: unknown }[] = [];
27
+ const mockNotificationTargetProbe: { hostId?: string; panel?: string }[] = [];
28
+ const mockAddNotification = jest.fn();
23
29
 
24
- jest.mock('../../hooks/members/useChannelAllMembers', () => ({
25
- useChannelAllMembers: jest.fn(),
30
+ jest.mock('../../../Notifications/hooks/useNotificationApi', () => ({
31
+ useNotificationApi: () => ({ addNotification: mockAddNotification }),
26
32
  }));
27
33
 
28
- jest.mock('react-native', () => {
29
- const actual = jest.requireActual('react-native');
30
-
31
- return new Proxy(actual, {
32
- get(target, prop, receiver) {
33
- if (prop === 'FlatList') {
34
- return (props: FlatListProps) => mockFlatList(props);
35
- }
34
+ jest.mock('../../../Notifications/NotificationTargetContext', () => ({
35
+ NotificationTargetProvider: ({
36
+ children,
37
+ hostId,
38
+ panel,
39
+ }: {
40
+ children: React.ReactNode;
41
+ hostId?: string;
42
+ panel?: string;
43
+ }) => {
44
+ mockNotificationTargetProbe.push({ hostId, panel });
45
+ return children;
46
+ },
47
+ }));
36
48
 
37
- return Reflect.get(target, prop, receiver);
38
- },
39
- });
49
+ jest.mock('../../../Notifications/NotificationList', () => {
50
+ const ReactLib = require('react');
51
+ const { View } = require('react-native');
52
+ return {
53
+ NotificationList: () => ReactLib.createElement(View, { testID: 'notification-list' }),
54
+ };
40
55
  });
41
56
 
42
- const channel = {
43
- cid: 'messaging:test',
44
- on: () => ({ unsubscribe: () => undefined }),
45
- } as unknown as Channel;
46
-
47
- type HookResult = ReturnType<typeof useChannelAllMembers>;
57
+ jest.mock('../../../../contexts/channelMemberListContext/ChannelMemberListContext', () => ({
58
+ ChannelMemberListProvider: ({
59
+ channel: providedChannel,
60
+ children,
61
+ }: {
62
+ channel: unknown;
63
+ children: React.ReactNode;
64
+ }) => {
65
+ mockProviderProbe.push({ channel: providedChannel });
66
+ return children;
67
+ },
68
+ useChannelMemberListContext: () => ({
69
+ channel: mockChannel,
70
+ searchSource: mockCurrentSearchSource,
71
+ }),
72
+ }));
48
73
 
49
- const baseHookResult = (): HookResult => ({
50
- hasMore: false,
51
- loading: false,
52
- loadMore: jest.fn(),
53
- results: [],
74
+ jest.mock('../../../UIComponents/SearchInput', () => {
75
+ const ReactLib = require('react');
76
+ const { Text } = require('react-native');
77
+ return {
78
+ SearchInput: ({ onChangeText }: { onChangeText: (t: string) => void }) =>
79
+ ReactLib.createElement(
80
+ ReactLib.Fragment,
81
+ null,
82
+ ReactLib.createElement(
83
+ Text,
84
+ { onPress: () => onChangeText('query'), testID: 'search-change' },
85
+ 'change',
86
+ ),
87
+ ReactLib.createElement(
88
+ Text,
89
+ { onPress: () => onChangeText(''), testID: 'search-clear' },
90
+ 'clear',
91
+ ),
92
+ ),
93
+ };
54
94
  });
55
95
 
56
- const mockHook = (overrides: Partial<HookResult> = {}) => {
57
- const value = { ...baseHookResult(), ...overrides };
58
- (useChannelAllMembers as jest.Mock).mockReturnValue(value);
59
- return value;
96
+ type FakeSearchSource = {
97
+ search: jest.Mock;
98
+ state: StateStore<
99
+ Pick<
100
+ SearchSourceState<ChannelMemberResponse>,
101
+ 'hasNext' | 'isLoading' | 'items' | 'lastQueryError' | 'searchQuery'
102
+ >
103
+ > & { partialNext: jest.Mock };
60
104
  };
61
105
 
62
- const itemProbeCalls: ChannelMemberItemProps[] = [];
63
- const MemberListItemProbe = (props: ChannelMemberItemProps) => {
64
- itemProbeCalls.push(props);
65
- return <Text testID={`member-${props.member.user?.id}`}>{props.member.user?.name}</Text>;
106
+ const makeSearchSource = (
107
+ overrides: Partial<{
108
+ hasNext: boolean;
109
+ isLoading: boolean;
110
+ items: ChannelMemberResponse[];
111
+ lastQueryError: Error;
112
+ searchQuery: string;
113
+ }> = {},
114
+ ): FakeSearchSource => {
115
+ const state = new StateStore({
116
+ hasNext: overrides.hasNext ?? false,
117
+ isLoading: overrides.isLoading ?? false,
118
+ items: overrides.items,
119
+ searchQuery: overrides.searchQuery ?? '',
120
+ ...(overrides.lastQueryError ? { lastQueryError: overrides.lastQueryError } : {}),
121
+ });
122
+ // The component calls state.partialNext on search input change; spy on it.
123
+ jest.spyOn(state, 'partialNext');
124
+ return {
125
+ search: jest.fn(),
126
+ state: state as FakeSearchSource['state'],
127
+ };
128
+ };
129
+
130
+ const MemberItemProbe = (props: ChannelMemberItemProps) => {
131
+ const { Text } = require('react-native');
132
+ mockItemProbe.push(props);
133
+ return (
134
+ <Text onPress={() => props.onPress?.(props.member)} testID={`member-${props.member.user?.id}`}>
135
+ {props.member.user?.id}
136
+ </Text>
137
+ );
66
138
  };
67
139
 
68
- const sheetProbeCalls: ChannelMemberActionsSheetProps[] = [];
69
140
  const MemberActionsSheetProbe = (props: ChannelMemberActionsSheetProps) => {
70
- sheetProbeCalls.push(props);
141
+ const { Text } = require('react-native');
142
+ mockSheetProbe.push(props);
71
143
  return <Text testID='member-actions-sheet-probe'>{props.member.user?.id ?? ''}</Text>;
72
144
  };
73
145
 
74
- const renderList = ({
75
- additionalFlatListProps,
76
- currentUserId,
77
- onMemberPress,
78
- }: {
79
- additionalFlatListProps?: Partial<FlatListProps>;
80
- currentUserId?: string;
81
- onMemberPress?: (member: ChannelMemberResponse) => void;
82
- } = {}) =>
83
- render(
146
+ const tree = (
147
+ searchSource: FakeSearchSource,
148
+ props: {
149
+ additionalFlatListProps?: object;
150
+ onMemberPress?: (member: ChannelMemberResponse) => void;
151
+ } = {},
152
+ ) => {
153
+ mockCurrentSearchSource = searchSource;
154
+ return (
84
155
  <ThemeProvider theme={defaultTheme}>
85
156
  <TranslationProvider
86
157
  value={{
@@ -89,161 +160,204 @@ const renderList = ({
89
160
  userLanguage: 'en',
90
161
  }}
91
162
  >
92
- <ChatContext.Provider
163
+ <ChannelDetailsContextProvider
93
164
  value={
94
165
  {
95
- client: {
96
- mutedUsers: [],
97
- on: () => ({ unsubscribe: () => undefined }),
98
- userID: currentUserId,
99
- },
100
- } as never
166
+ channel: mockChannel,
167
+ onMemberPress: props.onMemberPress,
168
+ } as unknown as ChannelDetailsContextValue
101
169
  }
102
170
  >
103
- <ChannelDetailsContextProvider value={{ channel, onMemberPress }}>
104
- <WithComponents
105
- overrides={{
106
- ChannelMemberActionsSheet: MemberActionsSheetProbe,
107
- ChannelMemberItem: MemberListItemProbe,
108
- }}
109
- >
110
- <ChannelMemberList additionalFlatListProps={additionalFlatListProps} />
111
- </WithComponents>
112
- </ChannelDetailsContextProvider>
113
- </ChatContext.Provider>
171
+ <WithComponents
172
+ overrides={{
173
+ ChannelMemberActionsSheet: MemberActionsSheetProbe,
174
+ ChannelMemberItem: MemberItemProbe,
175
+ }}
176
+ >
177
+ <ChannelMemberList additionalFlatListProps={props.additionalFlatListProps as never} />
178
+ </WithComponents>
179
+ </ChannelDetailsContextProvider>
114
180
  </TranslationProvider>
115
- </ThemeProvider>,
181
+ </ThemeProvider>
116
182
  );
117
-
118
- const latestListProps = () => {
119
- const calls = mockFlatList.mock.calls;
120
- return calls[calls.length - 1]?.[0];
121
183
  };
122
184
 
123
185
  describe('ChannelMemberList', () => {
124
186
  beforeEach(() => {
125
- mockFlatList.mockClear();
126
- itemProbeCalls.length = 0;
127
- sheetProbeCalls.length = 0;
128
- mockHook();
187
+ mockItemProbe.length = 0;
188
+ mockSheetProbe.length = 0;
189
+ mockProviderProbe.length = 0;
190
+ mockNotificationTargetProbe.length = 0;
129
191
  });
130
192
 
131
193
  afterEach(() => jest.clearAllMocks());
132
194
 
133
- it('renders the loading skeleton while loading with no results yet', () => {
134
- mockHook({ loading: true, results: [] });
195
+ it('wraps its content in the member list provider for the channel', () => {
196
+ render(tree(makeSearchSource()));
197
+
198
+ expect(mockProviderProbe).toHaveLength(1);
199
+ expect(mockProviderProbe[0].channel).toBe(mockChannel);
200
+ });
135
201
 
136
- const list = renderList();
202
+ it('calls search when the component is created', () => {
203
+ const searchSource = makeSearchSource();
204
+ render(tree(searchSource));
137
205
 
138
- expect(list.getByTestId('member-list-loading-skeleton')).toBeTruthy();
139
- expect(mockFlatList).not.toHaveBeenCalled();
206
+ expect(searchSource.search).toHaveBeenCalledTimes(1);
207
+ expect(searchSource.search).toHaveBeenCalledWith();
140
208
  });
141
209
 
142
- it('renders the list (not the skeleton) once results exist even while loading', () => {
143
- mockHook({
144
- loading: true,
145
- results: [generateMember({ user: generateUser({ id: 'alice' }) })],
210
+ it('wires the search input to the search source callbacks', () => {
211
+ const searchSource = makeSearchSource({
212
+ items: [generateMember({ user: generateUser({ id: 'alice' }) })],
146
213
  });
214
+ render(tree(searchSource));
215
+ searchSource.search.mockClear();
147
216
 
148
- const list = renderList();
217
+ fireEvent.press(screen.getByTestId('search-change'));
218
+ expect(searchSource.state.partialNext).toHaveBeenCalledWith({ searchQuery: 'query' });
219
+ expect(searchSource.search).toHaveBeenCalledWith('query');
149
220
 
150
- expect(list.queryByTestId('member-list-loading-skeleton')).toBeNull();
151
- expect(mockFlatList).toHaveBeenCalled();
221
+ fireEvent.press(screen.getByTestId('search-clear'));
222
+ expect(searchSource.search).toHaveBeenCalledWith('');
152
223
  });
153
224
 
154
- it('feeds the hook results into the flat list with a stable keyExtractor', () => {
225
+ it('renders a row per member and forwards a stable keyExtractor', () => {
155
226
  const alice = generateMember({ user: generateUser({ id: 'alice', name: 'Alice' }) });
156
227
  const bob = generateMember({ user: generateUser({ id: 'bob', name: 'Bob' }) });
157
- mockHook({ results: [alice, bob] });
158
228
 
159
- renderList();
229
+ render(tree(makeSearchSource({ items: [alice, bob] })));
160
230
 
161
- const props = latestListProps();
162
- expect((props?.data as ChannelMemberResponse[]).map((m) => m.user?.id)).toEqual([
163
- 'alice',
164
- 'bob',
165
- ]);
166
- expect(props?.keyExtractor?.(alice, 0)).toBe('alice');
231
+ expect(mockItemProbe.map((p) => p.member.user?.id)).toEqual(['alice', 'bob']);
232
+ expect(screen.getByTestId('member-alice')).toBeTruthy();
233
+ expect(screen.getByTestId('channel-member-list').props.keyExtractor(alice, 0)).toBe('alice');
167
234
  });
168
235
 
169
- it('wires onEndReached to loadMore (with threshold) only when there is more to load', () => {
170
- const loadMore = jest.fn();
171
- mockHook({ hasMore: true, loadMore, results: [] });
172
-
173
- renderList();
236
+ it('shows the loading skeleton and keeps the search bar on the initial load', () => {
237
+ render(tree(makeSearchSource({ isLoading: true })));
174
238
 
175
- const props = latestListProps();
176
- expect(props?.onEndReachedThreshold).toBe(0.2);
177
- expect(props?.onEndReached).toBe(loadMore);
239
+ expect(screen.getByTestId('member-list-loading-skeleton')).toBeTruthy();
240
+ expect(screen.getByTestId('search-change')).toBeTruthy();
178
241
  });
179
242
 
180
- it('omits onEndReached when there is no more to load', () => {
181
- mockHook({ hasMore: false, results: [] });
243
+ it('shows the empty search result and keeps the search bar when there are no results', () => {
244
+ render(tree(makeSearchSource({ isLoading: false, items: [] })));
182
245
 
183
- renderList();
246
+ expect(screen.getByTestId('empty-search-result')).toBeTruthy();
247
+ expect(screen.getByText('No members found')).toBeTruthy();
248
+ expect(screen.getByTestId('search-change')).toBeTruthy();
249
+ });
184
250
 
185
- expect(latestListProps()?.onEndReached).toBeUndefined();
251
+ it('renders the loading-more indicator only while loading with existing results', () => {
252
+ render(
253
+ tree(
254
+ makeSearchSource({
255
+ isLoading: true,
256
+ items: [generateMember({ user: generateUser({ id: 'alice' }) })],
257
+ }),
258
+ ),
259
+ );
260
+ expect(screen.UNSAFE_getByType(require('react-native').ActivityIndicator)).toBeTruthy();
186
261
  });
187
262
 
188
- it('renders a footer spinner only while loading more (loading with existing results)', () => {
189
- const results = [generateMember({ user: generateUser({ id: 'alice' }) })];
190
- mockHook({ loading: true, results });
191
- renderList();
192
- const footer = latestListProps()?.ListFooterComponent as React.ReactElement;
193
- expect(footer).not.toBeNull();
194
- expect(footer.type).toBe(ActivityIndicator);
263
+ it('loads more via the search source when the list end is reached and there is a next page', () => {
264
+ const searchSource = makeSearchSource({
265
+ hasNext: true,
266
+ items: [generateMember({ user: generateUser({ id: 'alice' }) })],
267
+ });
268
+ render(tree(searchSource));
269
+ searchSource.search.mockClear();
195
270
 
196
- mockFlatList.mockClear();
197
- mockHook({ loading: false, results });
198
- renderList();
199
- expect(latestListProps()?.ListFooterComponent).toBeNull();
271
+ const list = screen.getByTestId('channel-member-list');
272
+ expect(list.props.onEndReachedThreshold).toBe(0.2);
273
+ list.props.onEndReached();
274
+ expect(searchSource.search).toHaveBeenCalledTimes(1);
200
275
  });
201
276
 
202
- it('forwards additionalFlatListProps to the underlying flat list', () => {
203
- mockHook({ results: [generateMember({ user: generateUser({ id: 'alice' }) })] });
277
+ it('does not load more when there is no next page', () => {
278
+ const searchSource = makeSearchSource({
279
+ hasNext: false,
280
+ items: [generateMember({ user: generateUser({ id: 'alice' }) })],
281
+ });
282
+ render(tree(searchSource));
283
+ searchSource.search.mockClear();
284
+
285
+ screen.getByTestId('channel-member-list').props.onEndReached();
286
+ expect(searchSource.search).not.toHaveBeenCalled();
287
+ });
204
288
 
205
- renderList({ additionalFlatListProps: { bounces: false, testID: 'custom-member-list' } });
289
+ it('forwards additionalFlatListProps to the underlying list', () => {
290
+ render(
291
+ tree(makeSearchSource({ items: [generateMember({ user: generateUser({ id: 'alice' }) })] }), {
292
+ additionalFlatListProps: { bounces: false, testID: 'custom-list' },
293
+ }),
294
+ );
206
295
 
207
- const props = latestListProps();
208
- expect(props?.testID).toBe('custom-member-list');
209
- expect(props?.bounces).toBe(false);
296
+ const list = screen.getByTestId('custom-list');
297
+ expect(list.props.bounces).toBe(false);
298
+ expect(screen.queryByTestId('channel-member-list')).toBeNull();
210
299
  });
211
300
 
212
301
  it('opens the per-member actions sheet on press when no onMemberPress override is provided, and closes it', () => {
213
302
  const bob = generateMember({ user: generateUser({ id: 'bob', name: 'Bob' }) });
214
- mockHook({ results: [bob] });
215
-
216
- const list = renderList();
303
+ render(tree(makeSearchSource({ items: [bob] })));
217
304
 
218
- const { renderItem } = latestListProps() ?? {};
219
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
220
- render((renderItem as any)({ index: 0, item: bob, separators: {} as never }));
221
- const captured = itemProbeCalls.find((p) => p.member.user?.id === 'bob');
305
+ expect(screen.queryByTestId('member-actions-sheet-probe')).toBeNull();
222
306
 
223
- expect(list.queryByTestId('member-actions-sheet-probe')).toBeNull();
224
- act(() => captured?.onPress?.(bob));
225
- expect(list.getByTestId('member-actions-sheet-probe').props.children).toBe('bob');
307
+ fireEvent.press(screen.getByTestId('member-bob'));
308
+ expect(screen.getByTestId('member-actions-sheet-probe').props.children).toBe('bob');
226
309
 
227
- act(() => sheetProbeCalls[sheetProbeCalls.length - 1]?.onClose?.());
228
- expect(list.queryByTestId('member-actions-sheet-probe')).toBeNull();
310
+ act(() => mockSheetProbe[mockSheetProbe.length - 1]?.onClose?.());
311
+ expect(screen.queryByTestId('member-actions-sheet-probe')).toBeNull();
229
312
  });
230
313
 
231
314
  it('calls onMemberPress instead of opening the sheet when an override is provided', () => {
232
315
  const alice = generateMember({ user: generateUser({ id: 'alice', name: 'Alice' }) });
233
316
  const onMemberPress = jest.fn();
234
- mockHook({ results: [alice] });
235
-
236
- const list = renderList({ onMemberPress });
317
+ render(tree(makeSearchSource({ items: [alice] }), { onMemberPress }));
237
318
 
238
- const { renderItem } = latestListProps() ?? {};
239
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
240
- render((renderItem as any)({ index: 0, item: alice, separators: {} as never }));
241
- const captured = itemProbeCalls.find((p) => p.member.user?.id === 'alice');
242
-
243
- act(() => captured?.onPress?.(alice));
319
+ fireEvent.press(screen.getByTestId('member-alice'));
244
320
 
245
321
  expect(onMemberPress).toHaveBeenCalledTimes(1);
246
322
  expect(onMemberPress.mock.calls[0][0].user?.id).toBe('alice');
247
- expect(list.queryByTestId('member-actions-sheet-probe')).toBeNull();
323
+ expect(screen.queryByTestId('member-actions-sheet-probe')).toBeNull();
324
+ });
325
+
326
+ it('targets the channel-details panel with a channel-scoped notification host', () => {
327
+ render(tree(makeSearchSource()));
328
+
329
+ expect(mockNotificationTargetProbe).toHaveLength(1);
330
+ expect(mockNotificationTargetProbe[0]).toEqual({
331
+ hostId: 'channel-member-list:messaging:1',
332
+ panel: 'channel-details',
333
+ });
334
+ });
335
+
336
+ it('renders the notification list host', () => {
337
+ render(tree(makeSearchSource({ items: [] })));
338
+
339
+ expect(screen.getByTestId('notification-list')).toBeTruthy();
340
+ });
341
+
342
+ it('adds an error notification when the search source reports a query error', () => {
343
+ const lastQueryError = new Error('boom');
344
+ render(tree(makeSearchSource({ lastQueryError })));
345
+
346
+ expect(mockAddNotification).toHaveBeenCalledTimes(1);
347
+ expect(mockAddNotification).toHaveBeenCalledWith({
348
+ message: 'Failed to load members',
349
+ options: {
350
+ originalError: lastQueryError,
351
+ severity: 'error',
352
+ type: 'api:channel:query-members:failed',
353
+ },
354
+ origin: { context: { channel: mockChannel }, emitter: 'ChannelMemberList' },
355
+ });
356
+ });
357
+
358
+ it('does not add a notification when there is no query error', () => {
359
+ render(tree(makeSearchSource({ items: [] })));
360
+
361
+ expect(mockAddNotification).not.toHaveBeenCalled();
248
362
  });
249
363
  });
@@ -8,6 +8,7 @@ import { UserListLoadingSkeleton } from './UserListLoadingSkeleton';
8
8
 
9
9
  import { useChannelAddMembersContext } from '../../../../contexts/channelAddMembersContext/ChannelAddMembersContext';
10
10
  import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext';
11
+ import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
11
12
  import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext';
12
13
  import { getNotificationErrorOptions } from '../../../../hooks/actions/useChannelActions';
13
14
  import { useStateStore } from '../../../../hooks/useStateStore';
@@ -43,6 +44,11 @@ const listStateSelector = (state: SearchSourceState<UserResponse>) => {
43
44
  export const ChannelAddMembers = ({ additionalFlatListProps }: ChannelAddMembersProps) => {
44
45
  const { t } = useTranslationContext();
45
46
  const styles = useStyles();
47
+ const {
48
+ theme: {
49
+ channelDetails: { addMembers },
50
+ },
51
+ } = useTheme();
46
52
 
47
53
  const { channel } = useChannelDetailsContext();
48
54
  const { addNotification } = useNotificationApi();
@@ -103,14 +109,14 @@ export const ChannelAddMembers = ({ additionalFlatListProps }: ChannelAddMembers
103
109
  const loadingMoreIndicator = <>{loading && users && users.length > 0 && <ActivityIndicator />}</>;
104
110
 
105
111
  return (
106
- <View style={styles.container}>
112
+ <View style={[styles.container, addMembers.container]}>
107
113
  <SearchInput
108
114
  accessibilityLabel={t('a11y/Search users to add')}
109
115
  onChangeText={(text) => searchSource.search(text)}
110
116
  />
111
117
 
112
118
  <FlatList
113
- contentContainerStyle={styles.listContent}
119
+ contentContainerStyle={[styles.listContent, addMembers.listContent]}
114
120
  data={users}
115
121
  keyboardDismissMode='interactive'
116
122
  keyboardShouldPersistTaps='handled'
@@ -120,7 +126,7 @@ export const ChannelAddMembers = ({ additionalFlatListProps }: ChannelAddMembers
120
126
  onEndReached={loadMore}
121
127
  onEndReachedThreshold={0.2}
122
128
  renderItem={renderItem}
123
- style={styles.list}
129
+ style={[styles.list, addMembers.list]}
124
130
  testID='channel-add-members-list'
125
131
  {...additionalFlatListProps}
126
132
  />
@@ -140,7 +146,7 @@ const useStyles = () => {
140
146
  },
141
147
  listContent: {
142
148
  flexGrow: 1,
143
- paddingBottom: primitives.spacingXl,
149
+ paddingBottom: primitives.spacing3xl,
144
150
  },
145
151
  }),
146
152
  [],
@@ -6,8 +6,6 @@ import { useTranslationContext } from '../../../../contexts/translationContext/T
6
6
  import { useChannelMemberCount } from '../../../../hooks';
7
7
  import { useChannelOwnCapabilities } from '../../../../hooks/useChannelOwnCapabilities';
8
8
  import { UserAdd } from '../../../../icons/user-add';
9
- import { NotificationList } from '../../../Notifications/NotificationList';
10
- import { NotificationTargetProvider } from '../../../Notifications/NotificationTargetContext';
11
9
  import { Button } from '../../../ui/Button/Button';
12
10
  import { ChannelDetailsModal } from '../modal/Modal';
13
11
  import { ModalHeader } from '../modal/ModalHeader';
@@ -20,7 +18,10 @@ export type ChannelAllMembersModalProps = {
20
18
 
21
19
  type ChannelAllMembersModalContentProps = Omit<ChannelAllMembersModalProps, 'visible'>;
22
20
 
23
- const ChannelAllMembersModalBody = ({
21
+ /**
22
+ * @experimental This component is experimental and is subject to change.
23
+ */
24
+ export const ChannelAllMembersModalContent = ({
24
25
  onAddMembersPress,
25
26
  onClose,
26
27
  }: ChannelAllMembersModalContentProps) => {
@@ -52,32 +53,10 @@ const ChannelAllMembersModalBody = ({
52
53
  title={t('{{count}} members', { count: total })}
53
54
  />
54
55
  <ChannelMemberList />
55
- <NotificationList />
56
56
  </>
57
57
  );
58
58
  };
59
59
 
60
- /**
61
- * @experimental This component is experimental and is subject to change.
62
- */
63
- export const ChannelAllMembersModalContent = ({
64
- onAddMembersPress,
65
- onClose,
66
- }: ChannelAllMembersModalContentProps) => {
67
- const { channel } = useChannelDetailsContext();
68
- const notificationHostId = channel?.cid ? `channel-member-list:${channel.cid}` : undefined;
69
-
70
- if (!notificationHostId) {
71
- return null;
72
- }
73
-
74
- return (
75
- <NotificationTargetProvider hostId={notificationHostId} panel='channel-details'>
76
- <ChannelAllMembersModalBody onAddMembersPress={onAddMembersPress} onClose={onClose} />
77
- </NotificationTargetProvider>
78
- );
79
- };
80
-
81
60
  /**
82
61
  * @experimental This component is experimental and is subject to change.
83
62
  */