diff --git a/examples/ExpoMessaging/app/channel/[cid]/details/index.tsx b/examples/ExpoMessaging/app/channel/[cid]/details/index.tsx index b4f0d6b5da..da4b490d4f 100644 --- a/examples/ExpoMessaging/app/channel/[cid]/details/index.tsx +++ b/examples/ExpoMessaging/app/channel/[cid]/details/index.tsx @@ -1,15 +1,19 @@ import React, { useCallback, useContext, useState } from 'react'; +import { View } from 'react-native'; + import { Stack, useRouter } from 'expo-router'; import { - ChannelAddMembersModal, ChannelAllMembersModal, ChannelDetails, + ChannelDetailsActionsSection, ChannelDetailsContextProvider, + ChannelDetailsMemberSection, + ChannelDetailsNavigationSection, ChannelDetailsNavigationSectionType, GetChannelDetailsNavigationItems, - GetChannelMemberActionItems, + ChannelDetailsEditButton, WithComponents, } from 'stream-chat-expo'; @@ -23,7 +27,7 @@ const navigationItems: { files: 'files', }; -const Header = () => { +const EmptyHeader = () => { return null; }; @@ -48,20 +52,35 @@ 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), []); - const getChannelMemberActionItems = useCallback( - ({ defaultItems }) => defaultItems, - [], + const NavigationSection = useCallback( + () => , + [getNavigationItems], + ); + + const MemberSection = useCallback( + () => , + [handleAllMembersPress], + ); + + const renderHeaderRight = useCallback( + () => + channel ? ( + + + + + + ) : null, + [channel], + ); + + const ActionsSection = useCallback( + () => , + [popToRoot], ); if (!channel) { @@ -73,24 +92,21 @@ export default function ChannelDetailsScreen() { - - - - - - + + + + + ); diff --git a/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx index 013dfaf9bb..adfc094fe9 100644 --- a/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx @@ -5,12 +5,18 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { ChannelDetails, + ChannelDetailsActionsSection, + ChannelDetailsMemberSection, + ChannelDetailsNavigationSection, GetChannelDetailsNavigationItems, GetChannelMemberActionItems, - ChannelAddMembersModal, ChannelAllMembersModal, ChannelDetailsContextProvider, ChannelDetailsNavigationSectionType, + ChannelMemberActionsSheet, + ChannelMemberActionsSheetProps, + WithComponents, + ChannelDetailsActionsSectionProps, } from 'stream-chat-react-native'; import { SendDirectMessage } from '../icons/SendDirectMessage'; @@ -65,16 +71,17 @@ 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), []); + const ActionsSection = useCallback( + (props: ChannelDetailsActionsSectionProps) => ( + + ), + [popToRoot], + ); + const getChannelMemberActionItems = useCallback( ({ context, defaultItems }) => { // Don't offer sending a direct message to yourself. @@ -100,25 +107,40 @@ export const ChannelDetailsScreen: React.FC = ({ [navigation], ); - return ( - <> - , + [getNavigationItems], + ); + + // Handle view all members modal so we can close it after navigation is triggered by our custom action. + const MemberSection = useCallback( + () => , + [handleAllMembersPress], + ); + + const MemberActionsSheet = useCallback( + (props: ChannelMemberActionsSheetProps) => ( + - - - - - + ), + [getChannelMemberActionItems], + ); + + return ( + + + + + + ); }; diff --git a/package/src/components/ChannelDetails/ChannelDetails.tsx b/package/src/components/ChannelDetails/ChannelDetails.tsx index c14c3f4f51..c78b18aa2c 100644 --- a/package/src/components/ChannelDetails/ChannelDetails.tsx +++ b/package/src/components/ChannelDetails/ChannelDetails.tsx @@ -1,119 +1,22 @@ import React, { useMemo } from 'react'; import { ScrollView, StyleSheet, View } from 'react-native'; -import type { Channel, ChannelMemberResponse } from 'stream-chat'; - -import type { GetChannelDetailsNavigationItems } from './hooks/useChannelDetailsNavigationItems'; - -import { - ChannelDetailsContextProvider, - type ChannelDetailsContextValue, -} from '../../contexts/channelDetailsContext/channelDetailsContext'; import { useChannelDetailsContext } from '../../contexts/channelDetailsContext/channelDetailsContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import type { TranslationContextValue } from '../../contexts/translationContext/TranslationContext'; -import type { GetChannelActionItems } from '../../hooks/actions/useChannelActionItems'; -import type { GetChannelMemberActionItems } from '../../hooks/actions/useChannelMemberActionItems'; import { useIsDirectChat } from '../../hooks/useIsDirectChat'; import { primitives } from '../../theme'; -import { GlobalFileUploadRequest } from '../../types/types'; import { NotificationList } from '../Notifications/NotificationList'; import { NotificationTargetProvider } from '../Notifications/NotificationTargetContext'; -/** - * Resolves the trailing role label rendered next to a member row in the channel details screen. - * - * Return `null` or `undefined` to render no label for the given member. - */ -export type GetMemberRoleLabel = (params: { - channel: Channel; - member: ChannelMemberResponse; - t: TranslationContextValue['t']; -}) => string | null | undefined; - export type ChannelDetailsProps = { - channel: Channel; - /** - * Compress image with quality (from 0 to 1, where 1 is best quality). - * On iOS, values larger than 0.8 don't produce a noticeable quality increase in most images, - * while a value of 0.8 will reduce the file size by about half or less compared to a value of 1. - * Image picker defaults to 0.8 for iOS and 1 for Android - */ - compressImageQuality?: number; - /** - * Customize the list of action items rendered in the channel details actions section. - * - * Receives the default items the SDK produces for the current channel and returns the - * final list to render. Use this to filter, reorder, replace, or add items. - * - * The SDK still wires `onChannelDismiss` into the resulting `leave` and `deleteChannel` - * items (matched by `id`) after this callback runs, so those actions continue to dismiss - * the screen on success regardless of how the items are customized. - */ - getChannelActionItems?: GetChannelActionItems; - /** - * Customize the list of action items rendered in the per-member actions bottom sheet - * (the sheet that opens when a member row is tapped). - * - * Receives the default items the SDK produces for the tapped member (e.g. `muteUser`, - * `block`) and returns the final list to render. Use this to filter, reorder, replace, - * or add items — for example, to inject a "Send Direct Message" action in your app. - */ - getChannelMemberActionItems?: GetChannelMemberActionItems; - /** - * Customize the navigation rows rendered in the channel details navigation section. - * - * Receives the built-in `defaultItems` (and a `context`) and returns the rows to render. - * Map over `defaultItems` to override a row's `onPress` (e.g. to push your own screen) or - * to add/remove rows. Any row whose `onPress` you leave untouched keeps its built-in - * behavior (opening the built-in modal), including sections added in future SDK versions. - */ - getNavigationItems?: GetChannelDetailsNavigationItems; - /** - * Override the role label shown next to each member in the channel details screen. - * - * The default implementation labels members as `Owner` (channel creator), - * `Admin` (`user.role === 'admin'`), or `Moderator` (`channel_role === 'channel_moderator'`), - * 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. */ onBack?: () => void; - /** Fired after the channel is no longer available to the current user (delete, leave, or block actions). */ - onChannelDismiss?: () => void; - /** - * Fired when the user taps the "Edit" button in the channel details header. - * The button is only rendered when the current user has the `update-channel` - * capability. By default it opens the channel edit details modal. Not shown in direct (1:1) channels. - */ - onEditChannelPress?: () => void; - /** - * Fired when the user taps a member row. Receives the tapped member. - * - * Applies both to the member preview on the channel details screen and to the full - * list opened via the "view all members" modal. If omitted, the default behavior is - * to open the per-member actions bottom sheet (mute, block, etc.). - */ - onMemberPress?: (member: ChannelMemberResponse) => void; - /** - * Fired when the user taps the "view all members" button, by default it opens the members bottom sheet. - */ - onViewAllMembersPress?: () => void; - /** - * Override file upload request (used to upload channel image). By default it will use Stream's CDN. - * @param file File object to upload - */ - doFileUploadRequest?: GlobalFileUploadRequest; }; -export const ChannelDetailsContent = () => { +export const ChannelDetailsContent = ({ onBack }: Pick) => { const { channel } = useChannelDetailsContext(); const { theme: { @@ -140,7 +43,7 @@ export const ChannelDetailsContent = () => { containerOverride, ]} > - } /> + } onBack={onBack} /> @@ -154,68 +57,19 @@ export const ChannelDetailsContent = () => { /** * @experimental This component is experimental and is subject to change. */ -export const ChannelDetails = ({ - channel, - compressImageQuality, - doFileUploadRequest, - getChannelActionItems, - getChannelMemberActionItems, - getMemberRoleLabel, - getNavigationItems, - onAddMembersPress, - onBack, - onChannelDismiss, - onEditChannelPress, - onMemberPress, - onViewAllMembersPress, -}: ChannelDetailsProps) => { +export const ChannelDetails = ({ onBack }: ChannelDetailsProps) => { const { ChannelDetailsContent: ChannelDetailsContentOverride } = useComponentsContext(); - const value = useMemo( - () => ({ - channel, - compressImageQuality, - doFileUploadRequest, - getChannelActionItems, - getChannelMemberActionItems, - getMemberRoleLabel, - getNavigationItems, - onAddMembersPress, - onBack, - onChannelDismiss, - onEditChannelPress, - onMemberPress, - onViewAllMembersPress, - }), - [ - channel, - compressImageQuality, - doFileUploadRequest, - getChannelActionItems, - getChannelMemberActionItems, - getMemberRoleLabel, - getNavigationItems, - onAddMembersPress, - onBack, - onChannelDismiss, - onEditChannelPress, - onMemberPress, - onViewAllMembersPress, - ], - ); + const { channel } = useChannelDetailsContext(); const Content = ChannelDetailsContentOverride ?? ChannelDetailsContent; const notificationHostId = channel?.cid ? `channel-details:${channel.cid}` : undefined; - return ( - - {notificationHostId ? ( - - - - - ) : ( - - )} - + return notificationHostId ? ( + + + + + ) : ( + ); }; diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetails.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetails.test.tsx index 903b88a9ef..221b7ac232 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetails.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetails.test.tsx @@ -5,7 +5,10 @@ import { render, screen } from '@testing-library/react-native'; import { NotificationManager } from 'stream-chat'; import type { Channel } from 'stream-chat'; -import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { + ChannelDetailsContextProvider, + useChannelDetailsContext, +} from '../../../contexts/channelDetailsContext/channelDetailsContext'; import { ChatContext } from '../../../contexts/chatContext/ChatContext'; import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; @@ -64,9 +67,11 @@ const buildChannel = (capabilities: string[] = []) => const renderContent = () => render( - - - + + + + + , ); @@ -108,16 +113,18 @@ describe('ChannelDetailsContent', () => { useIsDirectChatSpy.mockReturnValue(false); render( - - - + + + + + , ); @@ -132,32 +139,34 @@ describe('ChannelDetails', () => { }); describe('context provisioning', () => { - it('exposes channel and callbacks via ChannelDetailsContext', () => { - const onChannelDismiss = jest.fn(); + it('exposes the channel via ChannelDetailsContext and forwards onBack to the content', () => { const onBack = jest.fn(); let captured: ReturnType | undefined; - const ContextProbe = () => { + let capturedOnBack: (() => void) | undefined; + const ContextProbe = ({ onBack: onBackProp }: { onBack?: () => void }) => { captured = useChannelDetailsContext(); + capturedOnBack = onBackProp; return null; }; render( - - - + + + + + , ); expect(captured).toBeDefined(); expect(captured?.channel).toBe(channel); - expect(captured?.onChannelDismiss).toBe(onChannelDismiss); - expect(captured?.onBack).toBe(onBack); + expect(capturedOnBack).toBe(onBack); }); }); @@ -166,14 +175,16 @@ describe('ChannelDetails', () => { const Override = () => CUSTOM; render( - - - + + + + + , ); @@ -191,9 +202,11 @@ describe('ChannelDetails', () => { // wasn't swapped out — the section probes from SECTION_OVERRIDES should appear. render( - - - + + + + + , ); expect(screen.getByTestId('probe-header')).toBeTruthy(); diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx index 4b9c0594d2..5be38f3ffa 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx @@ -53,17 +53,11 @@ const Providers = ({ children }: PropsWithChildren) => ( ); -const renderEditButton = ({ - channel, - onEditChannelPress, -}: { - channel: Channel; - onEditChannelPress?: () => void; -}) => +const renderEditButton = ({ channel }: { channel: Channel }) => render( - + @@ -106,16 +100,7 @@ describe('ChannelDetailsEditButton', () => { expect(screen.queryByTestId('channel-details-edit-button')).toBeNull(); }); - it('invokes onEditChannelPress when the Edit button is pressed', () => { - const onEditChannelPress = jest.fn(); - renderEditButton({ channel: buildChannel(['update-channel']), onEditChannelPress }); - - fireEvent.press(screen.getByTestId('channel-details-edit-button')); - - expect(onEditChannelPress).toHaveBeenCalledTimes(1); - }); - - it('opens the edit modal when the Edit button is pressed and onEditChannelPress is not provided', () => { + it('opens the edit modal when the Edit button is pressed', () => { renderEditButton({ channel: buildChannel(['update-channel']) }); expect(screen.queryByTestId('channel-edit-details-probe')).toBeNull(); diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsMemberSection.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsMemberSection.test.tsx index 21a6132daf..d6e6f4ad76 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetailsMemberSection.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsMemberSection.test.tsx @@ -77,14 +77,10 @@ const applyCapabilities = ( const renderSection = ({ capabilities, channel, - onAddMembersPress, - onMemberPress, onViewAllMembersPress, }: { channel: Channel; capabilities?: Partial; - onAddMembersPress?: () => void; - onMemberPress?: (member: ChannelMemberResponse) => void; onViewAllMembersPress?: () => void; }) => render( @@ -112,9 +108,6 @@ const renderSection = ({ - + @@ -209,23 +202,19 @@ describe('ChannelDetailsMemberSection', () => { 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); @@ -239,7 +228,7 @@ describe('ChannelDetailsMemberSection', () => { expect(screen.getByTestId('add-members-probe')).toBeTruthy(); }); - it('opens the per-member actions sheet when a member row is pressed and no onMemberPress override is provided', () => { + it('opens the per-member actions sheet when a member row is pressed', () => { const members = makeMembers(3); previewSpy.mockReturnValue({ hasMore: false, total: 3, visible: members }); const channel = buildChannel(members, 3); @@ -259,27 +248,7 @@ describe('ChannelDetailsMemberSection', () => { expect(screen.getByTestId('member-actions-sheet-probe').props.children).toBe('u-0'); }); - it('calls onMemberPress instead of opening the per-member actions sheet when provided', () => { - const members = makeMembers(3); - previewSpy.mockReturnValue({ hasMore: false, total: 3, visible: members }); - const channel = buildChannel(members, 3); - const onMemberPress = jest.fn(); - - renderSection({ channel, onMemberPress }); - - const lastCallForSecondMember = [...memberItemProbeCalls] - .reverse() - .find((call) => call.member.user?.id === 'u-1'); - act(() => { - lastCallForSecondMember?.onPress?.(lastCallForSecondMember.member); - }); - - expect(onMemberPress).toHaveBeenCalledTimes(1); - expect(onMemberPress.mock.calls[0][0].user?.id).toBe('u-1'); - 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 +261,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__/ChannelDetailsNavHeader.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavHeader.test.tsx index 3cf340855d..a36c5c8ccd 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavHeader.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavHeader.test.tsx @@ -59,8 +59,8 @@ const renderHeader = ({ }) => render( - - + + , ); @@ -100,8 +100,8 @@ describe('ChannelDetailsNavHeader', () => { rerender( - - + + , ); diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavigationSection.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavigationSection.test.tsx index 8c34e3735c..d5403e461b 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavigationSection.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavigationSection.test.tsx @@ -19,6 +19,7 @@ import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; import type { ChannelDetailsActionItemProps } from '../components/ChannelDetailsActionItem'; import { ChannelDetailsNavigationSection } from '../components/ChannelDetailsNavigationSection'; +import type { GetChannelDetailsNavigationItems } from '../hooks/useChannelDetailsNavigationItems'; const probeCalls: ChannelDetailsActionItemProps[] = []; @@ -67,7 +68,7 @@ jest.mock('../../ImageGallery/ImageGallery', () => { }); const renderSection = ( - contextValue: Partial = {}, + { getNavigationItems }: { getNavigationItems?: GetChannelDetailsNavigationItems } = {}, overlay: Overlay = 'none', ) => { const overlayContextValue: OverlayContextValue = { @@ -85,8 +86,8 @@ const renderSection = ( }} > - - + + 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/ChannelMemberActionsSheet.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberActionsSheet.test.tsx index 53b567a4d7..caf699d1eb 100644 --- a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberActionsSheet.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberActionsSheet.test.tsx @@ -168,9 +168,14 @@ describe('ChannelMemberActionsSheet', () => { userLanguage: 'en', }} > - + - + diff --git a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberItem.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberItem.test.tsx index 73e5c0161b..cf9a98d2ae 100644 --- a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberItem.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberItem.test.tsx @@ -12,8 +12,8 @@ import { defaultTheme } from '../../../../contexts/themeContext/utils/theme'; import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; import { generateMember } from '../../../../mock-builders/generator/member'; import { generateUser } from '../../../../mock-builders/generator/user'; -import type { GetMemberRoleLabel } from '../../ChannelDetails'; import { ChannelMemberItem } from '../../components/members/ChannelMemberItem'; +import type { GetMemberRoleLabel } from '../../hooks/members/useMemberRoleLabel'; Dayjs.extend(relativeTime); @@ -70,8 +70,8 @@ const renderRow = ({ } as never } > - - + + diff --git a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberList.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberList.test.tsx index 698bf74d7c..d51e075bb8 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'], }; @@ -147,7 +155,6 @@ const tree = ( searchSource: FakeSearchSource, props: { additionalFlatListProps?: object; - onMemberPress?: (member: ChannelMemberResponse) => void; } = {}, ) => { mockCurrentSearchSource = searchSource; @@ -164,7 +171,6 @@ const tree = ( value={ { channel: mockChannel, - onMemberPress: props.onMemberPress, } as unknown as ChannelDetailsContextValue } > @@ -188,6 +194,7 @@ describe('ChannelMemberList', () => { mockSheetProbe.length = 0; mockProviderProbe.length = 0; mockNotificationTargetProbe.length = 0; + mockMemberCount = 1; }); afterEach(() => jest.clearAllMocks()); @@ -298,7 +305,7 @@ describe('ChannelMemberList', () => { 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', () => { + it('opens the per-member actions sheet on press, and closes it', () => { const bob = generateMember({ user: generateUser({ id: 'bob', name: 'Bob' }) }); render(tree(makeSearchSource({ items: [bob] }))); @@ -311,18 +318,6 @@ describe('ChannelMemberList', () => { 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(); - render(tree(makeSearchSource({ items: [alice] }), { onMemberPress })); - - fireEvent.press(screen.getByTestId('member-alice')); - - expect(onMemberPress).toHaveBeenCalledTimes(1); - expect(onMemberPress.mock.calls[0][0].user?.id).toBe('alice'); - expect(screen.queryByTestId('member-actions-sheet-probe')).toBeNull(); - }); - it('targets the channel-details panel with a channel-scoped notification host', () => { render(tree(makeSearchSource())); @@ -360,4 +355,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/__tests__/members/useMemberRoleLabel.test.tsx b/package/src/components/ChannelDetails/__tests__/members/useMemberRoleLabel.test.tsx index ebc8978519..41bb88937b 100644 --- a/package/src/components/ChannelDetails/__tests__/members/useMemberRoleLabel.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/members/useMemberRoleLabel.test.tsx @@ -7,8 +7,10 @@ import { ChannelDetailsContextProvider } from '../../../../contexts/channelDetai import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; import { generateMember } from '../../../../mock-builders/generator/member'; import { generateUser } from '../../../../mock-builders/generator/user'; -import type { GetMemberRoleLabel } from '../../ChannelDetails'; -import { useMemberRoleLabel } from '../../hooks/members/useMemberRoleLabel'; +import { + type GetMemberRoleLabel, + useMemberRoleLabel, +} from '../../hooks/members/useMemberRoleLabel'; const buildChannel = (createdById = 'creator'): Channel => ({ @@ -37,12 +39,12 @@ const renderRoleLabel = ( getMemberRoleLabel, }: { channel?: Channel; getMemberRoleLabel?: GetMemberRoleLabel } = {}, ) => - renderHook(() => useMemberRoleLabel(member), { + renderHook(() => useMemberRoleLabel(member, getMemberRoleLabel), { wrapper: ({ children }) => ( input) as never, userLanguage: 'en' }} > - + {children} diff --git a/package/src/components/ChannelDetails/__tests__/useChannelDetailsActionItems.test.tsx b/package/src/components/ChannelDetails/__tests__/useChannelDetailsActionItems.test.tsx index d02f34db0a..8a80e65132 100644 --- a/package/src/components/ChannelDetails/__tests__/useChannelDetailsActionItems.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/useChannelDetailsActionItems.test.tsx @@ -28,7 +28,6 @@ const mockContext = ( ) => { const value: channelDetailsContextModule.ChannelDetailsContextValue = { channel, - onChannelDismiss: jest.fn(), ...overrides, }; jest.spyOn(channelDetailsContextModule, 'useChannelDetailsContext').mockReturnValue(value); @@ -58,12 +57,12 @@ describe('useChannelDetailsActionItems', () => { }); }); - it('forwards the getChannelActionItems prop from context unchanged', () => { + it('forwards the getChannelActionItems argument unchanged', () => { const getChannelActionItems: GetChannelActionItems = ({ defaultItems }) => defaultItems; - mockContext({ getChannelActionItems }); + mockContext(); const spy = mockUseChannelActionItems([]); - renderHook(() => useChannelDetailsActionItems()); + renderHook(() => useChannelDetailsActionItems({ getChannelActionItems })); expect(spy).toHaveBeenCalledWith({ channel, getChannelActionItems, surface: 'details' }); }); @@ -88,7 +87,8 @@ describe('useChannelDetailsActionItems', () => { ])( 'wraps the $id action to call onChannelDismiss after the original action resolves', async ({ id, label }) => { - const { onChannelDismiss } = mockContext(); + mockContext(); + const onChannelDismiss = jest.fn(); const callOrder: string[] = []; let resolveAction: (() => void) | undefined; @@ -110,7 +110,7 @@ describe('useChannelDetailsActionItems', () => { const item = buildItem({ action: originalAction, id, label, type: 'destructive' }); mockUseChannelActionItems([item]); - const { result } = renderHook(() => useChannelDetailsActionItems()); + const { result } = renderHook(() => useChannelDetailsActionItems({ onChannelDismiss })); const [wrapped] = result.current; expect(wrapped).not.toBe(item); @@ -132,11 +132,12 @@ describe('useChannelDetailsActionItems', () => { ); it('composes a caller-supplied onSuccess with onChannelDismiss and passes other options through', () => { - const { onChannelDismiss } = mockContext(); + mockContext(); + const onChannelDismiss = jest.fn(); const originalLeave = jest.fn(); mockUseChannelActionItems([buildItem({ action: originalLeave, id: 'leave' })]); - const { result } = renderHook(() => useChannelDetailsActionItems()); + const { result } = renderHook(() => useChannelDetailsActionItems({ onChannelDismiss })); const callerOnSuccess = jest.fn(); const callerOnFailure = jest.fn(); result.current[0].action({ @@ -161,7 +162,7 @@ describe('useChannelDetailsActionItems', () => { it.each([{ id: 'leave' }, { id: 'deleteChannel' }])( 'does not throw when onChannelDismiss is undefined on the $id path', async ({ id }) => { - mockContext({ onChannelDismiss: undefined }); + mockContext(); const originalAction = jest.fn().mockResolvedValue(undefined); mockUseChannelActionItems([buildItem({ action: originalAction, id })]); diff --git a/package/src/components/ChannelDetails/__tests__/useEditChannelImage.test.tsx b/package/src/components/ChannelDetails/__tests__/useEditChannelImage.test.tsx index d58c0e43ad..201ea52d55 100644 --- a/package/src/components/ChannelDetails/__tests__/useEditChannelImage.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/useEditChannelImage.test.tsx @@ -4,10 +4,11 @@ import { Alert } from 'react-native'; import { act, renderHook } from '@testing-library/react-native'; import type { Channel } from 'stream-chat'; -import { ChannelDetailsContextProvider } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { ChannelEditDetailsContext } from '../../../contexts/channelEditDetailsContext/ChannelEditDetailsContext'; import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; import { generateFileReference } from '../../../mock-builders/attachments'; import { NativeHandlers } from '../../../native'; +import { EditChannelDetailsStore } from '../../../state-store/edit-channel-details-store'; import { useEditChannelImage } from '../hooks/useEditChannelImage'; jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); @@ -29,9 +30,11 @@ const wrap = ({ compressImageQuality }: { compressImageQuality?: number }) => { userLanguage: 'en', }} > - + {children} - + ); return Wrapper; diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsActionsSection.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsActionsSection.tsx index 392be45dae..0b1d2f0286 100644 --- a/package/src/components/ChannelDetails/components/ChannelDetailsActionsSection.tsx +++ b/package/src/components/ChannelDetails/components/ChannelDetailsActionsSection.tsx @@ -4,7 +4,10 @@ import { StyleSheet, Switch, View } from 'react-native'; import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; -import { ChannelActionItem } from '../../../hooks/actions/useChannelActionItems'; +import { + ChannelActionItem, + GetChannelActionItems, +} from '../../../hooks/actions/useChannelActionItems'; import { getOtherUserInDirectChannel } from '../../../hooks/actions/useChannelActions'; import { useIsDirectChat } from '../../../hooks/useIsDirectChat'; import { primitives } from '../../../theme'; @@ -101,10 +104,29 @@ const UserMuteToggleRow = ({ item }: { item: ChannelActionItem }) => { ); }; +export type ChannelDetailsActionsSectionProps = { + /** + * Customize the list of action items rendered in the channel details actions section. + * + * Receives the default items the SDK produces for the current channel and returns the + * final list to render. Use this to filter, reorder, replace, or add items. + * + * The SDK still wires `onChannelDismiss` into the resulting `leave`, `deleteChannel`, and + * `block` items (matched by `id`) after this callback runs, so those actions continue to + * dismiss the screen on success regardless of how the items are customized. + */ + getChannelActionItems?: GetChannelActionItems; + /** Fired after the channel is no longer available to the current user (delete, leave, or block actions). */ + onChannelDismiss?: () => void; +}; + /** * @experimental This component is experimental and is subject to change. */ -export const ChannelDetailsActionsSection = () => { +export const ChannelDetailsActionsSection = ({ + getChannelActionItems, + onChannelDismiss, +}: ChannelDetailsActionsSectionProps) => { const { theme: { channelDetails: { sectionCard: sectionCardOverride }, @@ -114,7 +136,7 @@ export const ChannelDetailsActionsSection = () => { const { ChannelDetailsActionItem } = useComponentsContext(); const styles = useStyles(); - const items = useChannelDetailsActionItems(); + const items = useChannelDetailsActionItems({ getChannelActionItems, onChannelDismiss }); if (items.length === 0) return null; diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx index 8266ecf066..277dcae9cd 100644 --- a/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx +++ b/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx @@ -12,20 +12,14 @@ import { Button } from '../../ui/Button/Button'; * @experimental This component is experimental and is subject to change. */ export const ChannelDetailsEditButton = () => { - const { channel, onEditChannelPress } = useChannelDetailsContext(); + const { channel } = useChannelDetailsContext(); const { t } = useTranslationContext(); const ownCapabilities = useChannelOwnCapabilities(channel); const canUpdateChannel = ownCapabilities?.includes('update-channel') ?? false; const isDirect = useIsDirectChat(channel); const [editModalVisible, setEditModalVisible] = useState(false); - const handleEditPress = useCallback(() => { - if (onEditChannelPress) { - onEditChannelPress(); - return; - } - setEditModalVisible(true); - }, [onEditChannelPress]); + const handleEditPress = useCallback(() => setEditModalVisible(true), []); const handleEditModalClose = useCallback(() => setEditModalVisible(false), []); diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx index 24dc7a9cfa..5030e0bc51 100644 --- a/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx +++ b/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx @@ -3,27 +3,30 @@ 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'; +export type ChannelDetailsMemberSectionProps = { + /** + * Fired when the user taps the "view all members" button. By default it opens the members bottom sheet. + */ + onViewAllMembersPress?: () => void; +}; + /** * @experimental This component is experimental and is subject to change. */ -export const ChannelDetailsMemberSection = () => { - const { channel, onAddMembersPress, onMemberPress, onViewAllMembersPress } = - useChannelDetailsContext(); +export const ChannelDetailsMemberSection = ({ + onViewAllMembersPress, +}: ChannelDetailsMemberSectionProps) => { + const { channel } = useChannelDetailsContext(); const { t } = useTranslationContext(); - const ownCapabilities = useChannelOwnCapabilities(channel); - const updateChannelMembers = ownCapabilities?.includes('update-channel-members') ?? false; const { theme: { channelDetails: { @@ -38,11 +41,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,28 +58,11 @@ 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( - (member: ChannelMemberResponse) => { - if (onMemberPress) { - onMemberPress(member); - return; - } - setSelectedMember(member); - }, - [onMemberPress], + (member: ChannelMemberResponse) => setSelectedMember(member), + [], ); return ( @@ -94,20 +80,10 @@ export const ChannelDetailsMemberSection = () => { > {t('{{count}} members', { count: total })} - {updateChannelMembers ? ( - -