diff --git a/examples/ExpoMessaging/app/channel/[cid]/details/files.tsx b/examples/ExpoMessaging/app/channel/[cid]/details/files.tsx index 1205fe05b8..3339f8c22b 100644 --- a/examples/ExpoMessaging/app/channel/[cid]/details/files.tsx +++ b/examples/ExpoMessaging/app/channel/[cid]/details/files.tsx @@ -19,7 +19,7 @@ export default function ChannelFilesScreen() { return ( <> - + diff --git a/examples/ExpoMessaging/app/channel/[cid]/details/images.tsx b/examples/ExpoMessaging/app/channel/[cid]/details/images.tsx index 6b4454be7e..1e3eacfa94 100644 --- a/examples/ExpoMessaging/app/channel/[cid]/details/images.tsx +++ b/examples/ExpoMessaging/app/channel/[cid]/details/images.tsx @@ -19,7 +19,7 @@ export default function ChannelImagesScreen() { return ( <> - + diff --git a/examples/ExpoMessaging/app/channel/[cid]/details/index.tsx b/examples/ExpoMessaging/app/channel/[cid]/details/index.tsx index b4f0d6b5da..cdb31715c0 100644 --- a/examples/ExpoMessaging/app/channel/[cid]/details/index.tsx +++ b/examples/ExpoMessaging/app/channel/[cid]/details/index.tsx @@ -3,13 +3,17 @@ import React, { useCallback, useContext, useState } from 'react'; import { Stack, useRouter } from 'expo-router'; import { - ChannelAddMembersModal, ChannelAllMembersModal, ChannelDetails, + ChannelDetailsActionsSection, ChannelDetailsContextProvider, + ChannelDetailsMemberSection, + ChannelDetailsNavigationSection, ChannelDetailsNavigationSectionType, GetChannelDetailsNavigationItems, - GetChannelMemberActionItems, + ChannelDetailsEditButton, + useCanEditChannel, + useIsDirectChat, WithComponents, } from 'stream-chat-expo'; @@ -23,13 +27,16 @@ const navigationItems: { files: 'files', }; -const Header = () => { +const EmptyHeader = () => { return null; }; export default function ChannelDetailsScreen() { const router = useRouter(); const { channel } = useContext(AppContext); + const canEdit = useCanEditChannel(channel); + const isDirect = useIsDirectChat(channel); + const isEditButtonVisible = canEdit && !isDirect; const getNavigationItems = useCallback( ({ defaultItems }) => @@ -48,20 +55,33 @@ 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 +93,21 @@ export default function ChannelDetailsScreen() { - - - - - - + + + + + ); diff --git a/examples/ExpoMessaging/app/channel/[cid]/details/pinned.tsx b/examples/ExpoMessaging/app/channel/[cid]/details/pinned.tsx index a7fdf441bd..b58bbd6ec8 100644 --- a/examples/ExpoMessaging/app/channel/[cid]/details/pinned.tsx +++ b/examples/ExpoMessaging/app/channel/[cid]/details/pinned.tsx @@ -48,7 +48,7 @@ export default function ChannelPinnedMessagesScreen() { return ( <> - + diff --git a/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx index 013dfaf9bb..073f4fc1c6 100644 --- a/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx @@ -1,16 +1,20 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback } from 'react'; -import type { RouteProp } from '@react-navigation/native'; +import { useNavigation, type RouteProp } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { ChannelDetails, + ChannelDetailsActionsSection, + ChannelDetailsNavigationSection, GetChannelDetailsNavigationItems, GetChannelMemberActionItems, - ChannelAddMembersModal, - ChannelAllMembersModal, ChannelDetailsContextProvider, ChannelDetailsNavigationSectionType, + ChannelMemberActionsSheet, + WithComponents, + ChannelDetailsActionsSectionProps, + useChannelDetailsContext, } from 'stream-chat-react-native'; import { SendDirectMessage } from '../icons/SendDirectMessage'; @@ -40,12 +44,10 @@ const navigationItems: { files: 'ChannelFilesScreen', }; -export const ChannelDetailsScreen: React.FC = ({ - navigation, - route: { - params: { channel }, - }, -}) => { +const ChannelDetailsScreenInner = () => { + const navigation = useNavigation(); + const { channel, closeModals } = useChannelDetailsContext(); + const onBack = useCallback(() => navigation.goBack(), [navigation]); const getNavigationItems = useCallback( ({ defaultItems }) => @@ -65,15 +67,13 @@ 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 }) => { @@ -85,7 +85,7 @@ export const ChannelDetailsScreen: React.FC = ({ return [ { action: () => { - setAllMembersVisible(false); + closeModals(); navigation.navigate('NewDirectMessagingScreen', { initialUser: user }); return Promise.resolve(); }, @@ -97,28 +97,47 @@ export const ChannelDetailsScreen: React.FC = ({ ...defaultItems, ]; }, - [navigation], + [navigation, closeModals], ); - return ( - <> - [0]) => ( + + ), + [getNavigationItems], + ); + + const MemberActionsSheet = useCallback( + (props: Parameters[0]) => ( + - - - - - + ), + [getChannelMemberActionItems], + ); + + return ( + + + + ); +}; + +export const ChannelDetailsScreen: React.FC = ({ + route: { + params: { channel }, + }, +}) => { + return ( + + + ); }; diff --git a/examples/SampleApp/src/screens/ChannelFilesScreen.tsx b/examples/SampleApp/src/screens/ChannelFilesScreen.tsx index 02a265acf3..9c32f36afc 100644 --- a/examples/SampleApp/src/screens/ChannelFilesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelFilesScreen.tsx @@ -34,7 +34,7 @@ export const ChannelFilesScreen: React.FC = ({ return ( - + diff --git a/examples/SampleApp/src/screens/ChannelImagesScreen.tsx b/examples/SampleApp/src/screens/ChannelImagesScreen.tsx index 372aa523bf..14b9a18a5b 100644 --- a/examples/SampleApp/src/screens/ChannelImagesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelImagesScreen.tsx @@ -26,7 +26,7 @@ export const ChannelImagesScreen: React.FC = ({ return ( - + diff --git a/examples/SampleApp/src/screens/ChannelPinnedMessagesScreen.tsx b/examples/SampleApp/src/screens/ChannelPinnedMessagesScreen.tsx index f34846c966..4ace8fb3bb 100644 --- a/examples/SampleApp/src/screens/ChannelPinnedMessagesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelPinnedMessagesScreen.tsx @@ -106,7 +106,7 @@ export const ChannelPinnedMessagesScreen: React.FC - + diff --git a/package/src/components/ChannelDetails/ChannelDetails.tsx b/package/src/components/ChannelDetails/ChannelDetails.tsx index c14c3f4f51..541f5dee2c 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,10 @@ export const ChannelDetailsContent = () => { containerOverride, ]} > - } /> + } + onBack={onBack} + /> @@ -154,68 +60,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..c406bbfce5 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__/ChannelDetailsActionsSection.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsActionsSection.test.tsx index e99679f057..b8f270df03 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetailsActionsSection.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsActionsSection.test.tsx @@ -71,7 +71,7 @@ const sectionElement = () => ( } as never } > - + diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx index 4b9c0594d2..48c98e5732 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx @@ -1,7 +1,9 @@ import React, { PropsWithChildren } from 'react'; -import { Text, View } from 'react-native'; +import { StyleSheet, Text, View } from 'react-native'; import { fireEvent, render, screen } from '@testing-library/react-native'; + +type ReactTestInstance = ReturnType; import { NotificationManager } from 'stream-chat'; import type { Channel } from 'stream-chat'; @@ -13,12 +15,28 @@ import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; import { useChannelActions } from '../../../hooks/actions/useChannelActions'; -import * as useIsDirectChatModule from '../../../hooks/useIsDirectChat'; -import { ChannelDetailsEditButton } from '../components/ChannelDetailsEditButton'; +import { + ChannelDetailsEditButton, + ChannelDetailsEditButtonProps, +} from '../components/ChannelDetailsEditButton'; jest.mock('../../../hooks/actions/useChannelActions'); const mockedUseChannelActions = jest.mocked(useChannelActions); +// Walks up the rendered tree to confirm some host ancestor received `width: 'auto'` +// (the forwarded style overriding the Button's default `width: '100%'`). +const hasAncestorWithStyle = (instance: ReactTestInstance | null): boolean => { + for (let node = instance; node; node = node.parent) { + if (typeof node.type === 'string') { + const flattened = StyleSheet.flatten(node.props.style) ?? {}; + if (flattened.width === 'auto') { + return true; + } + } + } + return false; +}; + const EditDetailsProbe = () => ( edit-details @@ -55,16 +73,18 @@ const Providers = ({ children }: PropsWithChildren) => ( const renderEditButton = ({ channel, - onEditChannelPress, + onPress, + style, }: { channel: Channel; - onEditChannelPress?: () => void; + onPress?: () => void; + style?: ChannelDetailsEditButtonProps['style']; }) => render( - - - + + + , @@ -72,7 +92,6 @@ const renderEditButton = ({ describe('ChannelDetailsEditButton', () => { beforeEach(() => { - jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(false); mockedUseChannelActions.mockReturnValue({ updateImage: jest.fn(), updateName: jest.fn(), @@ -98,30 +117,41 @@ describe('ChannelDetailsEditButton', () => { expect(screen.getByText('Edit')).toBeTruthy(); }); - it('does not render the Edit button in a direct (1:1) channel even with the update-channel capability', () => { - jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(true); + it('forwards the style prop to the underlying Button', () => { + renderEditButton({ + channel: buildChannel(['update-channel']), + style: { width: 'auto' }, + }); - renderEditButton({ channel: buildChannel(['update-channel']) }); - - expect(screen.queryByTestId('channel-details-edit-button')).toBeNull(); + // The style lands on the Button's outer wrapper View, a host ancestor of the + // testID-carrying Pressable. Search ancestors for the one that received it. + expect(hasAncestorWithStyle(screen.getByTestId('channel-details-edit-button'))).toBe(true); }); - it('invokes onEditChannelPress when the Edit button is pressed', () => { - const onEditChannelPress = jest.fn(); - renderEditButton({ channel: buildChannel(['update-channel']), onEditChannelPress }); + it('opens the edit modal when the Edit button is pressed', () => { + renderEditButton({ channel: buildChannel(['update-channel']) }); + + expect(screen.queryByTestId('channel-edit-details-probe')).toBeNull(); fireEvent.press(screen.getByTestId('channel-details-edit-button')); - expect(onEditChannelPress).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('channel-edit-details-probe')).toBeTruthy(); }); - it('opens the edit modal when the Edit button is pressed and onEditChannelPress is not provided', () => { - renderEditButton({ channel: buildChannel(['update-channel']) }); + it('invokes the onPress override and does not open the modal', () => { + const onPress = jest.fn(); - expect(screen.queryByTestId('channel-edit-details-probe')).toBeNull(); + renderEditButton({ channel: buildChannel(['update-channel']), onPress }); fireEvent.press(screen.getByTestId('channel-details-edit-button')); - expect(screen.getByTestId('channel-edit-details-probe')).toBeTruthy(); + expect(onPress).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('channel-edit-details-probe')).toBeNull(); + }); + + it('does not mount the built-in modal when a custom onPress is provided', () => { + renderEditButton({ channel: buildChannel(['update-channel']), onPress: jest.fn() }); + + 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..522cad5f53 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( @@ -109,23 +105,16 @@ const renderSection = ({ } as never } > - + - + @@ -209,23 +198,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 +224,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 +244,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 +257,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..a6628ba96c 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..957c83ef4f 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,10 @@ const renderSection = ( }} > - - + + diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsProfile.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsProfile.test.tsx index f6a468b5ee..49592faad1 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetailsProfile.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsProfile.test.tsx @@ -46,7 +46,7 @@ const renderProfile = ({ channel = buildChannel() }: { channel?: Channel } = {}) }} > - + diff --git a/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsModal.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsForm.test.tsx similarity index 79% rename from package/src/components/ChannelDetails/__tests__/ChannelEditDetailsModal.test.tsx rename to package/src/components/ChannelDetails/__tests__/ChannelEditDetailsForm.test.tsx index b6abdb5ce2..7989047a72 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsModal.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsForm.test.tsx @@ -14,13 +14,14 @@ import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; import { useChannelActions } from '../../../hooks/actions/useChannelActions'; -import { ChannelEditDetailsModal } from '../components/ChannelEditDetailsModal'; +import { ChannelEditDetailsForm } from '../components/ChannelEditDetailsForm'; jest.mock('../../../hooks/actions/useChannelActions'); const mockedUseChannelActions = jest.mocked(useChannelActions); -// Stands in for ChannelEditDetails: drives the channel name through the store -// exposed by ChannelEditDetailsContext, the same way the real component does. +// Stands in for ChannelEditDetailsFormContent: drives the channel name through +// the store exposed by ChannelEditDetailsContext, the same way the real +// component does. const EditDetailsProbe = () => { const { store } = useChannelEditDetailsContext(); return ( @@ -51,15 +52,7 @@ const buildChannel = (overrides?: { name?: string; cid?: string }): Channel => state: { members: {} }, }) as unknown as Channel; -const renderModal = ({ - channel, - onClose = jest.fn(), - visible = true, -}: { - channel: Channel; - onClose?: () => void; - visible?: boolean; -}) => +const renderForm = ({ channel, onClose = jest.fn() }: { channel: Channel; onClose?: () => void }) => render( @@ -73,9 +66,9 @@ const renderModal = ({ - - - + + + @@ -84,13 +77,11 @@ const renderModal = ({ , ); -describe('ChannelEditDetailsModal', () => { +describe('ChannelEditDetailsForm', () => { let updateNameSpy: jest.Mock; beforeEach(() => { - updateNameSpy = jest.fn(async (_name: string, options?: { onSuccess?: () => unknown }) => { - await options?.onSuccess?.(); - }); + updateNameSpy = jest.fn().mockResolvedValue(undefined); mockedUseChannelActions.mockReturnValue({ updateName: updateNameSpy, } as unknown as ReturnType); @@ -102,7 +93,7 @@ describe('ChannelEditDetailsModal', () => { }); it('disables the confirm button on initial render when the name is unchanged', () => { - renderModal({ channel: buildChannel() }); + renderForm({ channel: buildChannel() }); expect( screen.getByTestId('channel-details-edit-confirm-button').props.accessibilityState, @@ -110,7 +101,7 @@ describe('ChannelEditDetailsModal', () => { }); it('enables the confirm button after typing a different name', () => { - renderModal({ channel: buildChannel() }); + renderForm({ channel: buildChannel() }); fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), 'Different'); @@ -120,7 +111,7 @@ describe('ChannelEditDetailsModal', () => { }); it('enables the confirm button after clearing a channel that previously had a name', () => { - renderModal({ channel: buildChannel({ name: 'Original' }) }); + renderForm({ channel: buildChannel({ name: 'Original' }) }); fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), ''); @@ -130,7 +121,7 @@ describe('ChannelEditDetailsModal', () => { }); it('enables confirm when the value differs from the initial name only by whitespace', () => { - renderModal({ channel: buildChannel({ name: 'Original' }) }); + renderForm({ channel: buildChannel({ name: 'Original' }) }); fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), ' Original '); @@ -140,7 +131,7 @@ describe('ChannelEditDetailsModal', () => { }); it('passes the raw (untrimmed) name to updateName when the user confirms', async () => { - renderModal({ channel: buildChannel({ name: 'Original' }) }); + renderForm({ channel: buildChannel({ name: 'Original' }) }); fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), ' Renamed '); @@ -151,12 +142,12 @@ describe('ChannelEditDetailsModal', () => { expect(updateNameSpy).toHaveBeenCalledWith( ' Renamed ', - expect.objectContaining({ onSuccess: expect.any(Function) }), + expect.objectContaining({ onFailure: expect.any(Function) }), ); }); it('passes an empty string to updateName when the user clears and confirms', async () => { - renderModal({ channel: buildChannel({ name: 'Original' }) }); + renderForm({ channel: buildChannel({ name: 'Original' }) }); fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), ''); @@ -167,13 +158,13 @@ describe('ChannelEditDetailsModal', () => { expect(updateNameSpy).toHaveBeenCalledWith( '', - expect.objectContaining({ onSuccess: expect.any(Function) }), + expect.objectContaining({ onFailure: expect.any(Function) }), ); }); - it('closes the modal after updateName invokes onSuccess', async () => { + it('closes the form after updateName succeeds', async () => { const onClose = jest.fn(); - renderModal({ channel: buildChannel({ name: 'Original' }), onClose }); + renderForm({ channel: buildChannel({ name: 'Original' }), onClose }); fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), 'Renamed'); @@ -185,10 +176,14 @@ describe('ChannelEditDetailsModal', () => { await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); }); - it('keeps the modal open and re-enables confirm when updateName does not invoke onSuccess', async () => { - updateNameSpy.mockResolvedValueOnce(undefined); + it('keeps the form open and re-enables confirm when updateName invokes onFailure', async () => { + updateNameSpy.mockImplementationOnce( + async (_name: string, options?: { onFailure?: (error: unknown) => unknown }) => { + await options?.onFailure?.(new Error('failed')); + }, + ); const onClose = jest.fn(); - renderModal({ channel: buildChannel({ name: 'Original' }), onClose }); + renderForm({ channel: buildChannel({ name: 'Original' }), onClose }); fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), 'Renamed'); @@ -212,7 +207,7 @@ describe('ChannelEditDetailsModal', () => { releaseUpdate = resolve; }), ); - renderModal({ channel: buildChannel({ name: 'Original' }) }); + renderForm({ channel: buildChannel({ name: 'Original' }) }); fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), 'Renamed'); @@ -233,7 +228,7 @@ describe('ChannelEditDetailsModal', () => { it('invokes onClose when the user taps the close button', () => { const onClose = jest.fn(); - renderModal({ channel: buildChannel({ name: 'Original' }), onClose }); + renderForm({ channel: buildChannel({ name: 'Original' }), onClose }); fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), 'Renamed'); fireEvent.press(screen.getByLabelText('a11y/Close')); @@ -242,7 +237,7 @@ describe('ChannelEditDetailsModal', () => { }); it('renders nothing when the channel has no cid (no notification host id)', () => { - renderModal({ channel: buildChannel({ cid: '' }) }); + renderForm({ channel: buildChannel({ cid: '' }) }); expect(screen.queryByTestId('channel-details-edit-confirm-button')).toBeNull(); }); diff --git a/package/src/components/ChannelDetails/__tests__/ChannelEditDetails.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsFormContent.test.tsx similarity index 95% rename from package/src/components/ChannelDetails/__tests__/ChannelEditDetails.test.tsx rename to package/src/components/ChannelDetails/__tests__/ChannelEditDetailsFormContent.test.tsx index 33fc51da7e..417f29e646 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelEditDetails.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsFormContent.test.tsx @@ -17,7 +17,7 @@ import { TranslationProvider } from '../../../contexts/translationContext/Transl import { generateFileReference } from '../../../mock-builders/attachments'; import { NativeHandlers } from '../../../native'; import { EditChannelDetailsStore } from '../../../state-store/edit-channel-details-store'; -import { ChannelEditDetails } from '../components/ChannelEditDetails'; +import { ChannelEditDetailsFormContent } from '../components/ChannelEditDetailsFormContent'; import type { ChannelEditImageSheetProps } from '../components/ChannelEditImageSheet'; type SheetProbeRecord = ChannelEditImageSheetProps; @@ -80,10 +80,10 @@ const renderComponent = ({ channel }: { channel: Channel }) => { { client: { on: () => ({ unsubscribe: () => undefined }), userID: 'me' } } as never } > - - + + - + @@ -96,7 +96,7 @@ const renderComponent = ({ channel }: { channel: Channel }) => { const latestSheetProps = () => sheetCalls[sheetCalls.length - 1]; -describe('ChannelEditDetails', () => { +describe('ChannelEditDetailsFormContent', () => { beforeEach(() => { sheetCalls.length = 0; }); @@ -128,14 +128,14 @@ describe('ChannelEditDetails', () => { { client: { on: () => ({ unsubscribe: () => undefined }), userID: 'me' } } as never } > - + - + diff --git a/package/src/components/ChannelDetails/__tests__/ChannelEditImageSheet.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelEditImageSheet.test.tsx index 79105ff5af..f07cd5c18c 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelEditImageSheet.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelEditImageSheet.test.tsx @@ -97,7 +97,7 @@ const renderSheet = ({ userLanguage: 'en', }} > - + diff --git a/package/src/components/ChannelDetails/__tests__/ChannelEditName.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelEditName.test.tsx index c1194cd6d1..8ab00f062a 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelEditName.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelEditName.test.tsx @@ -37,7 +37,7 @@ const renderComponent = ({ channel }: { channel: Channel }) => { { client: { on: () => ({ unsubscribe: () => undefined }), userID: 'me' } } as never } > - + 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..26c80b4884 --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersButton.test.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; + +type ReactTestInstance = ReturnType; +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 modal wrapper with a lightweight probe that renders regardless of `visible` +// (and ignores its children, so the real add-members form never mounts). This lets tests +// distinguish "modal mounted" from "modal not mounted" and observe its open/closed state. +jest.mock('../../components/modal/Modal', () => ({ + ChannelDetailsModal: ({ 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'; + +// Walks up the rendered tree to confirm some host ancestor received `width: 'auto'` +// (the forwarded style overriding the Button's default `width: '100%'`). +const hasAncestorWithStyle = (instance: ReactTestInstance | null): boolean => { + for (let node = instance; node; node = node.parent) { + if (typeof node.type === 'string') { + const flattened = StyleSheet.flatten(node.props.style) ?? {}; + if (flattened.width === 'auto') { + return true; + } + } + } + return false; +}; + +const renderButton = ({ + capabilities, + onPress, + style, + variant, +}: { + capabilities?: Partial; + onPress?: () => void; + style?: React.ComponentProps['style']; + 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.each(['text', 'icon'] as const)( + 'forwards the style prop to the underlying Button (%s variant)', + (variant) => { + renderButton({ + capabilities: { updateChannelMembers: true }, + style: { width: 'auto' }, + variant, + }); + + // The style lands on the Button's outer wrapper View, a host ancestor of the + // testID-carrying Pressable. Search ancestors for the one that received it. + expect(hasAncestorWithStyle(screen.getByTestId(TEST_ID))).toBe(true); + }, + ); + + 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/ChannelAddMembersModal.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersForm.test.tsx similarity index 83% rename from package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersModal.test.tsx rename to package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersForm.test.tsx index 27b0c338fd..a72ad9b386 100644 --- a/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersModal.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersForm.test.tsx @@ -21,13 +21,13 @@ import { TranslationProvider } from '../../../../contexts/translationContext/Tra import { useChannelActions } from '../../../../hooks/actions/useChannelActions'; import { generateMember } from '../../../../mock-builders/generator/member'; import { generateUser } from '../../../../mock-builders/generator/user'; -import { ChannelAddMembersModal } from '../../components/members/ChannelAddMembersModal'; +import { ChannelAddMembersForm } from '../../components/members/ChannelAddMembersForm'; jest.mock('../../../../hooks/actions/useChannelActions'); const mockedUseChannelActions = jest.mocked(useChannelActions); -// Stands in for the real ChannelAddMembers list: drives the shared selection store -// directly instead of running the search source / list. +// Stands in for the real ChannelAddMembersFormContent list: drives the shared +// selection store directly instead of running the search source / list. const AddMembersProbe = () => { const { selectionStore } = useChannelAddMembersContext(); return ( @@ -76,16 +76,14 @@ const applyCapabilities = ( return channel; }; -const renderModal = ({ +const renderForm = ({ capabilities, channel, onClose = jest.fn(), - visible = true, }: { channel: Channel; capabilities?: Partial; onClose?: () => void; - visible?: boolean; }) => render( @@ -100,11 +98,9 @@ const renderModal = ({ - - - + + + @@ -118,13 +114,11 @@ const makeMembers = (count: number) => generateMember({ user: generateUser({ id: `u-${idx}`, name: `User ${idx}` }) }), ); -describe('ChannelAddMembersModal', () => { +describe('ChannelAddMembersForm', () => { let addMembersSpy: jest.Mock; beforeEach(() => { - addMembersSpy = jest.fn(async (_ids: string[], options?: { onSuccess?: () => unknown }) => { - await options?.onSuccess?.(); - }); + addMembersSpy = jest.fn().mockResolvedValue(undefined); mockedUseChannelActions.mockReturnValue({ addMembers: addMembersSpy, } as unknown as ReturnType); @@ -135,10 +129,10 @@ describe('ChannelAddMembersModal', () => { mockedUseChannelActions.mockReset(); }); - it('enables the confirm button only while ChannelAddMembers reports a selection', () => { + it('enables the confirm button only while the form content reports a selection', () => { const channel = buildChannel(makeMembers(3), 3); - renderModal({ channel }); + renderForm({ channel }); const confirm = screen.getByTestId('channel-details-add-members-confirm-button'); expect(confirm.props.accessibilityState).toMatchObject({ disabled: true }); @@ -154,11 +148,11 @@ describe('ChannelAddMembersModal', () => { ).toMatchObject({ disabled: true }); }); - it('calls addMembers from useChannelActions with the selected user ids and closes the sheet on confirm', async () => { + it('calls addMembers from useChannelActions with the selected user ids and closes the form on confirm', async () => { const channel = buildChannel(makeMembers(3), 3); const onClose = jest.fn(); - renderModal({ channel, onClose }); + renderForm({ channel, onClose }); fireEvent.press(screen.getByTestId('probe-select-one')); @@ -169,7 +163,7 @@ describe('ChannelAddMembersModal', () => { expect(addMembersSpy).toHaveBeenCalledWith( ['picked-1'], - expect.objectContaining({ onSuccess: expect.any(Function) }), + expect.objectContaining({ onFailure: expect.any(Function) }), ); expect(channel.addMembers).not.toHaveBeenCalled(); await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); @@ -179,19 +173,23 @@ describe('ChannelAddMembersModal', () => { const channel = buildChannel(makeMembers(3), 3); const onClose = jest.fn(); - renderModal({ channel, onClose }); + renderForm({ channel, onClose }); fireEvent.press(screen.getByLabelText('a11y/Close')); expect(onClose).toHaveBeenCalledTimes(1); }); - it('keeps the sheet open and re-enables confirm when addMembers does not invoke onSuccess', async () => { + it('keeps the form open and re-enables confirm when addMembers invokes onFailure', async () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); - addMembersSpy.mockResolvedValueOnce(undefined); + addMembersSpy.mockImplementationOnce( + async (_ids: string[], options?: { onFailure?: (error: unknown) => unknown }) => { + await options?.onFailure?.(new Error('failed')); + }, + ); const channel = buildChannel(makeMembers(3), 3); const onClose = jest.fn(); - renderModal({ channel, onClose }); + renderForm({ channel, onClose }); fireEvent.press(screen.getByTestId('probe-select-one')); @@ -203,7 +201,7 @@ describe('ChannelAddMembersModal', () => { expect(addMembersSpy).toHaveBeenCalledWith( ['picked-1'], - expect.objectContaining({ onSuccess: expect.any(Function) }), + expect.objectContaining({ onFailure: expect.any(Function) }), ); expect(onClose).not.toHaveBeenCalled(); expect(screen.getByTestId('add-members-probe')).toBeTruthy(); diff --git a/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembers.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersFormContent.test.tsx similarity index 96% rename from package/src/components/ChannelDetails/__tests__/members/ChannelAddMembers.test.tsx rename to package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersFormContent.test.tsx index 2111d7ad08..71351c8d9c 100644 --- a/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembers.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersFormContent.test.tsx @@ -11,7 +11,7 @@ import { TranslationProvider } from '../../../../contexts/translationContext/Tra import { generateUser } from '../../../../mock-builders/generator/user'; import { SelectionStore } from '../../../../state-store/selection-store'; import type { AddMemberSearchResultItemProps } from '../../components/members/AddMemberSearchResultItem'; -import { ChannelAddMembers } from '../../components/members/ChannelAddMembers'; +import { ChannelAddMembersFormContent } from '../../components/members/ChannelAddMembersFormContent'; const mockRowProbe: AddMemberSearchResultItemProps[] = []; @@ -98,13 +98,15 @@ const tree = ( }} > - + ); -describe('ChannelAddMembers', () => { +describe('ChannelAddMembersFormContent', () => { beforeEach(() => { mockRowProbe.length = 0; }); diff --git a/package/src/components/ChannelDetails/__tests__/members/ChannelAllMembersModal.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelAllMembersModal.test.tsx deleted file mode 100644 index f29accd61f..0000000000 --- a/package/src/components/ChannelDetails/__tests__/members/ChannelAllMembersModal.test.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import React from 'react'; -import { Text } from 'react-native'; - -import { fireEvent, render, screen } from '@testing-library/react-native'; -import { NotificationManager } from 'stream-chat'; -import type { Channel, ChannelMemberResponse } from 'stream-chat'; - -import { AccessibilityProvider } from '../../../../contexts/accessibilityContext/AccessibilityContext'; -import { ChannelDetailsContextProvider } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; -import { ChatContext } from '../../../../contexts/chatContext/ChatContext'; -import { WithComponents } from '../../../../contexts/componentsContext/ComponentsContext'; -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 { generateMember } from '../../../../mock-builders/generator/member'; -import { generateUser } from '../../../../mock-builders/generator/user'; -import { ChannelAllMembersModal } from '../../components/members/ChannelAllMembersModal'; -import * as useChannelDetailsMembersPreviewModule from '../../hooks/useChannelDetailsMembersPreview'; - -const MemberListProbe = () => full-member-list; - -const buildChannel = ( - members: ChannelMemberResponse[], - memberCount?: number, - overrides?: Partial, -): Channel => - ({ - cid: 'messaging:test', - data: { member_count: memberCount ?? members.length }, - on: () => ({ unsubscribe: () => undefined }), - state: { - members: Object.fromEntries( - members.map((m) => [m.user?.id ?? m.user_id ?? '', m]).filter(([k]) => Boolean(k)), - ), - }, - ...overrides, - }) as unknown as Channel; - -const applyCapabilities = ( - channel: Channel, - overrides?: Partial, -): Channel => { - if (!overrides) return channel; - const ownCapabilities = Object.entries(overrides) - .filter(([, enabled]) => enabled) - .map(([key]) => allOwnCapabilities[key as OwnCapability]); - (channel as { data?: Record }).data = { - ...((channel as { data?: Record }).data ?? {}), - own_capabilities: ownCapabilities, - }; - return channel; -}; - -const renderModal = ({ - capabilities, - channel, - onAddMembersPress = jest.fn(), - onClose = jest.fn(), - visible = true, -}: { - channel: Channel; - capabilities?: Partial; - onAddMembersPress?: () => void; - onClose?: () => void; - visible?: boolean; -}) => - render( - - - key) as never, - tDateTimeParser: ((input: unknown) => input) as never, - userLanguage: 'en', - }} - > - - - - - - - - - - , - ); - -const makeMembers = (count: number) => - Array.from({ length: count }, (_, idx) => - generateMember({ user: generateUser({ id: `u-${idx}`, name: `User ${idx}` }) }), - ); - -describe('ChannelAllMembersModal', () => { - let previewSpy: jest.SpyInstance; - - beforeEach(() => { - previewSpy = jest.spyOn( - useChannelDetailsMembersPreviewModule, - 'useChannelDetailsMembersPreview', - ); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('renders the member list and closes when the close button is pressed', () => { - previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); - const channel = buildChannel(makeMembers(12), 12); - const onClose = jest.fn(); - - renderModal({ channel, onClose }); - - expect(screen.getByTestId('member-list-probe')).toBeTruthy(); - - fireEvent.press(screen.getByLabelText('a11y/Close')); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('hides the add-members button when the user lacks update-channel-members capability', () => { - previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); - const channel = buildChannel(makeMembers(12), 12); - - renderModal({ channel }); - - expect(screen.queryByTestId('channel-details-member-list-add-button')).toBeNull(); - }); - - it('shows the add-members button and invokes onAddMembersPress 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, - }); - - fireEvent.press(screen.getByTestId('channel-details-member-list-add-button')); - - expect(onAddMembersPress).toHaveBeenCalledTimes(1); - }); -}); diff --git a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberActionsSheet.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberActionsSheet.test.tsx index 53b567a4d7..7d9cb54f11 100644 --- a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberActionsSheet.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberActionsSheet.test.tsx @@ -81,7 +81,7 @@ const renderSheet = ({ } as never } > - + @@ -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..c20d98bc14 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..11960849d0 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 }), })); @@ -66,7 +72,6 @@ jest.mock('../../../../contexts/channelMemberListContext/ChannelMemberListContex return children; }, useChannelMemberListContext: () => ({ - channel: mockChannel, searchSource: mockCurrentSearchSource, }), })); @@ -94,6 +99,7 @@ jest.mock('../../../UIComponents/SearchInput', () => { }); type FakeSearchSource = { + resetStateAndActivate: jest.Mock; search: jest.Mock; state: StateStore< Pick< @@ -122,6 +128,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 +154,6 @@ const tree = ( searchSource: FakeSearchSource, props: { additionalFlatListProps?: object; - onMemberPress?: (member: ChannelMemberResponse) => void; } = {}, ) => { mockCurrentSearchSource = searchSource; @@ -161,12 +167,7 @@ const tree = ( }} > { mockSheetProbe.length = 0; mockProviderProbe.length = 0; mockNotificationTargetProbe.length = 0; + mockMemberCount = 1; }); afterEach(() => jest.clearAllMocks()); @@ -298,7 +300,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 +313,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 +350,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..e45c5dd977 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,14 +39,12 @@ const renderRoleLabel = ( getMemberRoleLabel, }: { channel?: Channel; getMemberRoleLabel?: GetMemberRoleLabel } = {}, ) => - renderHook(() => useMemberRoleLabel(member), { + renderHook(() => useMemberRoleLabel(member, getMemberRoleLabel), { wrapper: ({ children }) => ( input) as never, userLanguage: 'en' }} > - - {children} - + {children} ), }); diff --git a/package/src/components/ChannelDetails/__tests__/useChannelDetailsActionItems.test.tsx b/package/src/components/ChannelDetails/__tests__/useChannelDetailsActionItems.test.tsx index d02f34db0a..d25ecb466c 100644 --- a/package/src/components/ChannelDetails/__tests__/useChannelDetailsActionItems.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/useChannelDetailsActionItems.test.tsx @@ -7,6 +7,7 @@ import type { GetChannelActionItems, } from '../../../hooks/actions/useChannelActionItems'; import * as useChannelActionItemsModule from '../../../hooks/actions/useChannelActionItems'; +import { SignalStore } from '../../../state-store/signal-store'; import { useChannelDetailsActionItems } from '../hooks/useChannelDetailsActionItems'; const NoopIcon = () => null; @@ -28,7 +29,8 @@ const mockContext = ( ) => { const value: channelDetailsContextModule.ChannelDetailsContextValue = { channel, - onChannelDismiss: jest.fn(), + closeModals: jest.fn(), + signalStore: new SignalStore(), ...overrides, }; jest.spyOn(channelDetailsContextModule, 'useChannelDetailsContext').mockReturnValue(value); @@ -58,12 +60,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 +90,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 +113,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 +135,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 +165,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..fc5fee06e4 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,15 @@ 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..fbcda78605 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; @@ -173,6 +195,7 @@ const useStyles = () => { borderRadius: primitives.radiusLg, overflow: 'hidden', paddingVertical: primitives.spacingXs, + paddingHorizontal: primitives.spacingXxs, }, }), [], diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx index 8266ecf066..19a6bacb87 100644 --- a/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx +++ b/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx @@ -1,35 +1,49 @@ import React, { useCallback, useState } from 'react'; -import { ChannelEditDetailsModal } from './ChannelEditDetailsModal'; +import { ChannelDetailsModal } from './modal/Modal'; import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; -import { useChannelOwnCapabilities } from '../../../hooks/useChannelOwnCapabilities'; -import { useIsDirectChat } from '../../../hooks/useIsDirectChat'; -import { Button } from '../../ui/Button/Button'; +import { useCanEditChannel } from '../../../hooks/useCanEditChannel'; +import { Button, ButtonProps } from '../../ui/Button/Button'; + +export type ChannelDetailsEditButtonProps = { + /** Override the default behavior, which opens the Edit modal. */ + onPress?: () => void; + /** Style forwarded to the underlying Button. */ + style?: ButtonProps['style']; +}; /** * @experimental This component is experimental and is subject to change. */ -export const ChannelDetailsEditButton = () => { - const { channel, onEditChannelPress } = useChannelDetailsContext(); +export const ChannelDetailsEditButton = ({ + onPress, + style, +}: ChannelDetailsEditButtonProps = {}) => { + const { channel } = useChannelDetailsContext(); + const { ChannelEditDetailsForm } = useComponentsContext(); const { t } = useTranslationContext(); - const ownCapabilities = useChannelOwnCapabilities(channel); - const canUpdateChannel = ownCapabilities?.includes('update-channel') ?? false; - const isDirect = useIsDirectChat(channel); + const isVisible = useCanEditChannel(channel); const [editModalVisible, setEditModalVisible] = useState(false); + // The built-in modal is only used by the default press behavior. When a custom handler + // (onPress prop) is provided, the consumer owns the navigation/modal, so we must not render + // the built-in one. + const usesDefaultModal = !onPress; + const handleEditPress = useCallback(() => { - if (onEditChannelPress) { - onEditChannelPress(); + if (onPress) { + onPress(); return; } setEditModalVisible(true); - }, [onEditChannelPress]); + }, [onPress]); const handleEditModalClose = useCallback(() => setEditModalVisible(false), []); - if (!canUpdateChannel || isDirect) { + if (!isVisible) { return null; } @@ -40,11 +54,16 @@ export const ChannelDetailsEditButton = () => { label={t('Edit')} onPress={handleEditPress} size='sm' + style={style} testID='channel-details-edit-button' type='outline' variant='secondary' /> - + {usesDefaultModal ? ( + + + + ) : null} ); }; diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx index 24dc7a9cfa..5505292245 100644 --- a/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx +++ b/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx @@ -3,27 +3,31 @@ 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 { ChannelDetailsModal } from './modal/Modal'; +import { ModalHeader } from './modal/ModalHeader'; 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: { @@ -32,17 +36,22 @@ export const ChannelDetailsMemberSection = () => { header: headerOverride, headerTitle: headerTitleOverride, viewAllLabel: viewAllLabelOverride, + addButtonWrapper: addButtonWrapperOverride, }, sectionCard: sectionCardOverride, }, semantics, }, } = useTheme(); - const { ChannelMemberActionsSheet, ChannelMemberItem } = useComponentsContext(); + const { + ChannelAddMembersButton, + ChannelMemberActionsSheet, + ChannelMemberItem, + ChannelMemberList, + } = 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 +64,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 +86,13 @@ export const ChannelDetailsMemberSection = () => { > {t('{{count}} members', { count: total })} - {updateChannelMembers ? ( - -