From 379eab34ca4b1558d7cf75807fcd669064526602 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 18 Jun 2026 12:29:11 -0500 Subject: [PATCH 1/4] refactor: add members button is a separate component now --- .../app/channel/[cid]/details/index.tsx | 15 +-- .../src/screens/ChannelDetailsScreen.tsx | 14 +- .../ChannelDetails/ChannelDetails.tsx | 7 - .../ChannelDetailsMemberSection.test.tsx | 19 +-- .../members/ChannelAddMembersButton.test.tsx | 124 ++++++++++++++++++ .../members/ChannelAllMembersModal.test.tsx | 25 ++-- .../members/ChannelMemberList.test.tsx | 33 +++++ .../ChannelDetailsMemberSection.tsx | 54 ++------ .../members/ChannelAddMembersButton.tsx | 82 ++++++++++++ .../members/ChannelAllMembersModal.tsx | 34 +---- .../components/members/ChannelMemberList.tsx | 14 +- .../components/members/index.ts | 1 + .../componentsContext/defaultComponents.ts | 2 + 13 files changed, 286 insertions(+), 138 deletions(-) create mode 100644 package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersButton.test.tsx create mode 100644 package/src/components/ChannelDetails/components/members/ChannelAddMembersButton.tsx diff --git a/examples/ExpoMessaging/app/channel/[cid]/details/index.tsx b/examples/ExpoMessaging/app/channel/[cid]/details/index.tsx index b4f0d6b5da..34d6308275 100644 --- a/examples/ExpoMessaging/app/channel/[cid]/details/index.tsx +++ b/examples/ExpoMessaging/app/channel/[cid]/details/index.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useContext, useState } from 'react'; import { Stack, useRouter } from 'expo-router'; import { - ChannelAddMembersModal, ChannelAllMembersModal, ChannelDetails, ChannelDetailsContextProvider, @@ -48,13 +47,6 @@ export default function ChannelDetailsScreen() { const popToRoot = useCallback(() => router.replace('/'), [router]); - const [isAddMembersVisible, setAddMembersVisible] = useState(false); - const handleAddMembersClose = useCallback(() => setAddMembersVisible(false), []); - const handleAddMembersPress = useCallback(() => { - setAllMembersVisible(false); - setAddMembersVisible(true); - }, []); - const [isAllMembersVisible, setAllMembersVisible] = useState(false); const handleAllMembersClose = useCallback(() => setAllMembersVisible(false), []); const handleAllMembersPress = useCallback(() => setAllMembersVisible(true), []); @@ -85,12 +77,7 @@ export default function ChannelDetailsScreen() { /> - - + ); diff --git a/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx index 013dfaf9bb..b38c1029a1 100644 --- a/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx @@ -7,7 +7,6 @@ import { ChannelDetails, GetChannelDetailsNavigationItems, GetChannelMemberActionItems, - ChannelAddMembersModal, ChannelAllMembersModal, ChannelDetailsContextProvider, ChannelDetailsNavigationSectionType, @@ -65,12 +64,6 @@ export const ChannelDetailsScreen: React.FC = ({ }), [navigation], ); - const [isAddMembersVisible, setAddMembersVisible] = useState(false); - const handleAddMembersClose = useCallback(() => setAddMembersVisible(false), []); - const handleAddMembersPress = useCallback(() => { - setAllMembersVisible(false); - setAddMembersVisible(true); - }, []); const [isAllMembersVisible, setAllMembersVisible] = useState(false); const handleAllMembersClose = useCallback(() => setAllMembersVisible(false), []); const handleAllMembersPress = useCallback(() => setAllMembersVisible(true), []); @@ -112,12 +105,7 @@ export const ChannelDetailsScreen: React.FC = ({ onViewAllMembersPress={handleAllMembersPress} /> - - + ); diff --git a/package/src/components/ChannelDetails/ChannelDetails.tsx b/package/src/components/ChannelDetails/ChannelDetails.tsx index c14c3f4f51..d20d715693 100644 --- a/package/src/components/ChannelDetails/ChannelDetails.tsx +++ b/package/src/components/ChannelDetails/ChannelDetails.tsx @@ -78,10 +78,6 @@ export type ChannelDetailsProps = { * with priority Owner > Admin > Moderator. Return `null` to render no label. */ getMemberRoleLabel?: GetMemberRoleLabel; - /** - * Fired when the user taps the "add members" button, by default it opens the add members bottom sheet. Only visible if the current user has the `update-channel-members` capability. - */ - onAddMembersPress?: () => void; /** * Fired when the back button is pressed on the channel details header. */ @@ -162,7 +158,6 @@ export const ChannelDetails = ({ getChannelMemberActionItems, getMemberRoleLabel, getNavigationItems, - onAddMembersPress, onBack, onChannelDismiss, onEditChannelPress, @@ -179,7 +174,6 @@ export const ChannelDetails = ({ getChannelMemberActionItems, getMemberRoleLabel, getNavigationItems, - onAddMembersPress, onBack, onChannelDismiss, onEditChannelPress, @@ -194,7 +188,6 @@ export const ChannelDetails = ({ getChannelMemberActionItems, getMemberRoleLabel, getNavigationItems, - onAddMembersPress, onBack, onChannelDismiss, onEditChannelPress, diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsMemberSection.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsMemberSection.test.tsx index 21a6132daf..031eff0d2b 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetailsMemberSection.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsMemberSection.test.tsx @@ -77,13 +77,11 @@ const applyCapabilities = ( const renderSection = ({ capabilities, channel, - onAddMembersPress, onMemberPress, onViewAllMembersPress, }: { channel: Channel; capabilities?: Partial; - onAddMembersPress?: () => void; onMemberPress?: (member: ChannelMemberResponse) => void; onViewAllMembersPress?: () => void; }) => @@ -112,7 +110,6 @@ const renderSection = ({ { expect(screen.queryByTestId('channel-details-member-section-add-button')).toBeNull(); }); - it('renders the preview add button and invokes onAddMembersPress when the user has the capability', () => { + it('renders the preview add button when the user has the capability', () => { previewSpy.mockReturnValue({ hasMore: false, total: 3, visible: makeMembers(3) }); const channel = buildChannel(makeMembers(3), 3); - const onAddMembersPress = jest.fn(); renderSection({ capabilities: { updateChannelMembers: true }, channel, - onAddMembersPress, }); - fireEvent.press(screen.getByTestId('channel-details-member-section-add-button')); - - expect(onAddMembersPress).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('channel-details-member-section-add-button')).toBeTruthy(); }); - it('opens the Add-members sheet when the preview Add is pressed and no onAddMembersPress override is provided', () => { + it('opens the Add-members sheet when the preview Add is pressed', () => { previewSpy.mockReturnValue({ hasMore: false, total: 3, visible: makeMembers(3) }); const channel = buildChannel(makeMembers(3), 3); @@ -279,7 +272,7 @@ describe('ChannelDetailsMemberSection', () => { expect(screen.queryByTestId('member-actions-sheet-probe')).toBeNull(); }); - it('swaps the all-members modal for the Add-members sheet when the modal Add button is pressed', () => { + it('opens the Add-members sheet on top of the all-members modal when the modal Add button is pressed', () => { previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); const channel = buildChannel(makeMembers(12), 12); @@ -292,7 +285,7 @@ describe('ChannelDetailsMemberSection', () => { fireEvent.press(screen.getByTestId('channel-details-member-list-add-button')); expect(screen.getByTestId('add-members-probe')).toBeTruthy(); - // View-all sheet is dismissed when Add-members opens (swap, not stack). - expect(screen.queryByTestId('member-list-probe')).toBeNull(); + // Add-members opens layered over the all-members list (stack, not swap). + expect(screen.getByTestId('member-list-probe')).toBeTruthy(); }); }); diff --git a/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersButton.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersButton.test.tsx new file mode 100644 index 0000000000..5738eeaaee --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersButton.test.tsx @@ -0,0 +1,124 @@ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import { AccessibilityProvider } from '../../../../contexts/accessibilityContext/AccessibilityContext'; +import { ChannelDetailsContextProvider } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { + allOwnCapabilities, + OwnCapabilitiesContextValue, + OwnCapability, +} from '../../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; +import { ThemeProvider } from '../../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; +import { ChannelAddMembersButton } from '../../components/members/ChannelAddMembersButton'; + +// Replace the heavy built-in modal with a lightweight probe that renders regardless of +// `visible`, so tests can distinguish "modal mounted" from "modal not mounted" and observe +// its open/closed state. +jest.mock('../../components/members/ChannelAddMembersModal', () => ({ + ChannelAddMembersModal: ({ visible }: { visible: boolean }) => { + const { Text: RNText } = require('react-native'); + return {visible ? 'visible' : 'hidden'}; + }, +})); + +const buildChannel = (overrides?: Partial): Channel => { + const ownCapabilities = overrides + ? Object.entries(overrides) + .filter(([, enabled]) => enabled) + .map(([key]) => allOwnCapabilities[key as OwnCapability]) + : undefined; + return { + cid: 'messaging:test', + data: ownCapabilities ? { own_capabilities: ownCapabilities } : {}, + on: () => ({ unsubscribe: () => undefined }), + state: { members: {} }, + } as unknown as Channel; +}; + +const TEST_ID = 'channel-details-add-members-button'; + +const renderButton = ({ + capabilities, + onPress, + variant, +}: { + capabilities?: Partial; + onPress?: () => void; + variant?: 'icon' | 'text'; +} = {}) => + render( + + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + + + + + , + ); + +describe('ChannelAddMembersButton', () => { + it('renders nothing when the user lacks the update-channel-members capability', () => { + renderButton(); + + expect(screen.queryByTestId(TEST_ID)).toBeNull(); + }); + + it('renders the text variant when the user has the capability', () => { + renderButton({ capabilities: { updateChannelMembers: true }, variant: 'text' }); + + expect(screen.getByTestId(TEST_ID)).toBeTruthy(); + expect(screen.getByText('Add')).toBeTruthy(); + }); + + it('renders the icon variant when the user has the capability', () => { + renderButton({ capabilities: { updateChannelMembers: true }, variant: 'icon' }); + + expect(screen.getByTestId(TEST_ID)).toBeTruthy(); + // The icon variant has no visible label. + expect(screen.queryByText('Add')).toBeNull(); + }); + + it('defaults to the text variant', () => { + renderButton({ capabilities: { updateChannelMembers: true } }); + + expect(screen.getByText('Add')).toBeTruthy(); + }); + + it('invokes the onPress override and does not open the modal', () => { + const onPress = jest.fn(); + + renderButton({ capabilities: { updateChannelMembers: true }, onPress }); + + fireEvent.press(screen.getByTestId(TEST_ID)); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('mounts the built-in modal and opens it on press when no override is provided', () => { + renderButton({ capabilities: { updateChannelMembers: true } }); + + expect(screen.getByTestId('add-members-modal')).toHaveTextContent('hidden'); + + fireEvent.press(screen.getByTestId(TEST_ID)); + + expect(screen.getByTestId('add-members-modal')).toHaveTextContent('visible'); + }); + + it('does not mount the built-in modal when a custom onPress is provided', () => { + renderButton({ capabilities: { updateChannelMembers: true }, onPress: jest.fn() }); + + expect(screen.queryByTestId('add-members-modal')).toBeNull(); + }); +}); diff --git a/package/src/components/ChannelDetails/__tests__/members/ChannelAllMembersModal.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelAllMembersModal.test.tsx index f29accd61f..42cea53cad 100644 --- a/package/src/components/ChannelDetails/__tests__/members/ChannelAllMembersModal.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelAllMembersModal.test.tsx @@ -22,6 +22,15 @@ import { generateUser } from '../../../../mock-builders/generator/user'; import { ChannelAllMembersModal } from '../../components/members/ChannelAllMembersModal'; import * as useChannelDetailsMembersPreviewModule from '../../hooks/useChannelDetailsMembersPreview'; +// Replace the heavy built-in add-members modal with a lightweight probe that reflects its +// visibility, so tests can observe the default "open the add-members modal" behavior. +jest.mock('../../components/members/ChannelAddMembersModal', () => ({ + ChannelAddMembersModal: ({ visible }: { visible: boolean }) => { + const { Text: RNText } = require('react-native'); + return {visible ? 'visible' : 'hidden'}; + }, +})); + const MemberListProbe = () => full-member-list; const buildChannel = ( @@ -59,13 +68,11 @@ const applyCapabilities = ( const renderModal = ({ capabilities, channel, - onAddMembersPress = jest.fn(), onClose = jest.fn(), visible = true, }: { channel: Channel; capabilities?: Partial; - onAddMembersPress?: () => void; onClose?: () => void; visible?: boolean; }) => @@ -86,11 +93,7 @@ const renderModal = ({ value={{ channel: applyCapabilities(channel, capabilities) }} > - + @@ -141,19 +144,19 @@ describe('ChannelAllMembersModal', () => { expect(screen.queryByTestId('channel-details-member-list-add-button')).toBeNull(); }); - it('shows the add-members button and invokes onAddMembersPress when pressed', () => { + it('shows the add-members button and opens the add-members modal when pressed', () => { previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); const channel = buildChannel(makeMembers(12), 12); - const onAddMembersPress = jest.fn(); renderModal({ capabilities: { updateChannelMembers: true }, channel, - onAddMembersPress, }); + expect(screen.getByTestId('add-members-modal')).toHaveTextContent('hidden'); + fireEvent.press(screen.getByTestId('channel-details-member-list-add-button')); - expect(onAddMembersPress).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('add-members-modal')).toHaveTextContent('visible'); }); }); diff --git a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberList.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberList.test.tsx index 698bf74d7c..38ed5857aa 100644 --- a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberList.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberList.test.tsx @@ -27,6 +27,12 @@ const mockProviderProbe: { channel: unknown }[] = []; const mockNotificationTargetProbe: { hostId?: string; panel?: string }[] = []; const mockAddNotification = jest.fn(); +let mockMemberCount = 1; + +jest.mock('../../../../hooks/useChannelMemberCount', () => ({ + useChannelMemberCount: () => mockMemberCount, +})); + jest.mock('../../../Notifications/hooks/useNotificationApi', () => ({ useNotificationApi: () => ({ addNotification: mockAddNotification }), })); @@ -94,6 +100,7 @@ jest.mock('../../../UIComponents/SearchInput', () => { }); type FakeSearchSource = { + resetStateAndActivate: jest.Mock; search: jest.Mock; state: StateStore< Pick< @@ -122,6 +129,7 @@ const makeSearchSource = ( // The component calls state.partialNext on search input change; spy on it. jest.spyOn(state, 'partialNext'); return { + resetStateAndActivate: jest.fn(), search: jest.fn(), state: state as FakeSearchSource['state'], }; @@ -188,6 +196,7 @@ describe('ChannelMemberList', () => { mockSheetProbe.length = 0; mockProviderProbe.length = 0; mockNotificationTargetProbe.length = 0; + mockMemberCount = 1; }); afterEach(() => jest.clearAllMocks()); @@ -360,4 +369,28 @@ describe('ChannelMemberList', () => { expect(mockAddNotification).not.toHaveBeenCalled(); }); + + it('refreshes the list when the member count changes and there is no search query', () => { + const searchSource = makeSearchSource({ searchQuery: '' }); + const { rerender } = render(tree(searchSource)); + expect(searchSource.search).toHaveBeenCalledTimes(1); + searchSource.search.mockClear(); + + mockMemberCount = 2; + rerender(tree(searchSource)); + + expect(searchSource.search).toHaveBeenCalledTimes(1); + expect(searchSource.search).toHaveBeenCalledWith(); + }); + + it('does not refresh the list when the member count changes while a search query is active', () => { + const searchSource = makeSearchSource({ searchQuery: 'alice' }); + const { rerender } = render(tree(searchSource)); + searchSource.search.mockClear(); + + mockMemberCount = 2; + rerender(tree(searchSource)); + + expect(searchSource.search).not.toHaveBeenCalled(); + }); }); diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx index 24dc7a9cfa..bb65f85720 100644 --- a/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx +++ b/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx @@ -3,27 +3,21 @@ import { I18nManager, Pressable, StyleSheet, Text, View } from 'react-native'; import type { ChannelMemberResponse } from 'stream-chat'; -import { ChannelAddMembersModal } from './members/ChannelAddMembersModal'; import { ChannelAllMembersModal } from './members/ChannelAllMembersModal'; import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; -import { useChannelOwnCapabilities } from '../../../hooks/useChannelOwnCapabilities'; import { primitives } from '../../../theme'; -import { Button } from '../../ui/Button/Button'; import { useChannelDetailsMembersPreview } from '../hooks/useChannelDetailsMembersPreview'; /** * @experimental This component is experimental and is subject to change. */ export const ChannelDetailsMemberSection = () => { - const { channel, onAddMembersPress, onMemberPress, onViewAllMembersPress } = - useChannelDetailsContext(); + const { channel, onMemberPress, onViewAllMembersPress } = useChannelDetailsContext(); const { t } = useTranslationContext(); - const ownCapabilities = useChannelOwnCapabilities(channel); - const updateChannelMembers = ownCapabilities?.includes('update-channel-members') ?? false; const { theme: { channelDetails: { @@ -38,11 +32,11 @@ export const ChannelDetailsMemberSection = () => { semantics, }, } = useTheme(); - const { ChannelMemberActionsSheet, ChannelMemberItem } = useComponentsContext(); + const { ChannelAddMembersButton, ChannelMemberActionsSheet, ChannelMemberItem } = + useComponentsContext(); const { hasMore, total, visible } = useChannelDetailsMembersPreview(channel); const styles = useStyles(); const [isMemberListVisible, setMemberListVisible] = useState(false); - const [isAddMembersVisible, setAddMembersVisible] = useState(false); const [selectedMember, setSelectedMember] = useState(null); const handleViewAllPress = useCallback(() => { @@ -55,17 +49,6 @@ export const ChannelDetailsMemberSection = () => { const handleMemberListClose = useCallback(() => setMemberListVisible(false), []); - const handleAddMembersClose = useCallback(() => setAddMembersVisible(false), []); - - const handleAddMembersPress = useCallback(() => { - if (onAddMembersPress) { - onAddMembersPress(); - return; - } - setMemberListVisible(false); - setAddMembersVisible(true); - }, [onAddMembersPress]); - const handleMemberActionsClose = useCallback(() => setSelectedMember(null), []); const handleMemberPress = useCallback( @@ -94,20 +77,10 @@ export const ChannelDetailsMemberSection = () => { > {t('{{count}} members', { count: total })} - {updateChannelMembers ? ( - -