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 ? (
-
-
-
- ) : null}
+
+
+
{visible.map((member) => {
@@ -133,12 +118,21 @@ export const ChannelDetailsMemberSection = () => {
) : null}
-
-
+ {onViewAllMembersPress ? null : (
+
+
+ }
+ title={t('{{count}} members', { count: total })}
+ />
+
+
+ )}
{selectedMember ? (
paddingHorizontal: primitives.spacingMd,
paddingTop: primitives.spacingXs,
},
- headerAddButton: {
- flexShrink: 0,
- },
- headerAddButtonInner: {
- width: 'auto',
- },
headerTitle: {
flexShrink: 1,
fontSize: primitives.typographyFontSizeMd,
@@ -182,6 +170,7 @@ const useStyles = () =>
},
list: {
paddingBottom: primitives.spacingSm,
+ paddingHorizontal: primitives.spacingXxs,
},
sectionCard: {
borderRadius: primitives.radiusLg,
@@ -198,6 +187,12 @@ const useStyles = () =>
fontWeight: primitives.typographyFontWeightSemiBold,
lineHeight: primitives.typographyLineHeightNormal,
},
+ addButtonWrapper: {
+ height: 48,
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
}),
[],
);
diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsNavHeader.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsNavHeader.tsx
index 77adcbd899..20b34799f9 100644
--- a/package/src/components/ChannelDetails/components/ChannelDetailsNavHeader.tsx
+++ b/package/src/components/ChannelDetails/components/ChannelDetailsNavHeader.tsx
@@ -14,6 +14,8 @@ import { Button } from '../../ui/Button/Button';
export type ChannelDetailsNavHeaderProps = {
/** Content rendered on the trailing side of the header (e.g. the edit button). */
action?: React.ReactNode;
+ /** Fired when the back button is pressed. The back button only renders when provided. */
+ onBack?: () => void;
/** Override the auto-resolved screen title (1:1 → "Contact Info", group → "Group Info"). */
title?: string;
};
@@ -21,8 +23,12 @@ export type ChannelDetailsNavHeaderProps = {
/**
* @experimental This component is experimental and is subject to change.
*/
-export const ChannelDetailsNavHeader = ({ action, title }: ChannelDetailsNavHeaderProps) => {
- const { channel, onBack } = useChannelDetailsContext();
+export const ChannelDetailsNavHeader = ({
+ action,
+ onBack,
+ title,
+}: ChannelDetailsNavHeaderProps) => {
+ const { channel } = useChannelDetailsContext();
const { t } = useTranslationContext();
const {
theme: {
diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsNavigationSection.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsNavigationSection.tsx
index b00390d0dd..18a6c589eb 100644
--- a/package/src/components/ChannelDetails/components/ChannelDetailsNavigationSection.tsx
+++ b/package/src/components/ChannelDetails/components/ChannelDetailsNavigationSection.tsx
@@ -14,13 +14,28 @@ import { primitives } from '../../../theme';
import { ImageGallery } from '../../ImageGallery/ImageGallery';
import {
type ChannelDetailsNavigationSectionType,
+ type GetChannelDetailsNavigationItems,
useChannelDetailsNavigationItems,
} from '../hooks/useChannelDetailsNavigationItems';
+export type ChannelDetailsNavigationSectionProps = {
+ /**
+ * 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;
+};
+
/**
* @experimental This component is experimental and is subject to change.
*/
-export const ChannelDetailsNavigationSection = () => {
+export const ChannelDetailsNavigationSection = ({
+ getNavigationItems,
+}: ChannelDetailsNavigationSectionProps) => {
const {
theme: {
channelDetails: { sectionCard: sectionCardOverride },
@@ -29,7 +44,7 @@ export const ChannelDetailsNavigationSection = () => {
} = useTheme();
const styles = useStyles();
const { FileAttachmentList, MediaList, PinnedMessageList } = useComponentsContext();
- const items = useChannelDetailsNavigationItems();
+ const items = useChannelDetailsNavigationItems({ getNavigationItems });
const [activeSection, setActiveSection] = useState(
null,
);
@@ -119,6 +134,7 @@ const useStyles = () => {
borderRadius: primitives.radiusLg,
overflow: 'hidden',
paddingVertical: primitives.spacingXs,
+ paddingHorizontal: primitives.spacingXxs,
},
}),
[],
diff --git a/package/src/components/ChannelDetails/components/ChannelEditDetailsForm.tsx b/package/src/components/ChannelDetails/components/ChannelEditDetailsForm.tsx
new file mode 100644
index 0000000000..1d5d8f5854
--- /dev/null
+++ b/package/src/components/ChannelDetails/components/ChannelEditDetailsForm.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+
+import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext';
+import { ChannelEditDetailsProvider } from '../../../contexts/channelEditDetailsContext/ChannelEditDetailsContext';
+import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext';
+import type { GlobalFileUploadRequest } from '../../../types/types';
+import { NotificationList } from '../../Notifications/NotificationList';
+
+export type ChannelEditDetailsFormProps = {
+ /** Called when the form is dismissed via the close button or after a successful save. */
+ onClose: () => void;
+ /**
+ * Compress image with quality (from 0 to 1, where 1 is best quality) applied
+ * to the channel image picked during editing.
+ */
+ compressImageQuality?: number;
+ /** Override the upload request used to upload the channel image. */
+ doFileUploadRequest?: GlobalFileUploadRequest;
+};
+
+/**
+ * Renders the channel edit details form: a header with a confirm action, the
+ * editable fields, and the notification list. Wrap-free — mount it wherever the
+ * edit UI should appear (e.g. inside a modal or a navigation screen).
+ *
+ * @experimental This component is experimental and is subject to change.
+ */
+export const ChannelEditDetailsForm = ({
+ compressImageQuality,
+ doFileUploadRequest,
+ onClose,
+}: ChannelEditDetailsFormProps) => {
+ const { channel } = useChannelDetailsContext();
+ const { ChannelEditDetailsFormContent, ChannelEditDetailsFormHeader } = useComponentsContext();
+
+ if (!channel?.cid) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/package/src/components/ChannelDetails/components/ChannelEditDetails.tsx b/package/src/components/ChannelDetails/components/ChannelEditDetailsFormContent.tsx
similarity index 98%
rename from package/src/components/ChannelDetails/components/ChannelEditDetails.tsx
rename to package/src/components/ChannelDetails/components/ChannelEditDetailsFormContent.tsx
index d90cde94a7..f78f099f40 100644
--- a/package/src/components/ChannelDetails/components/ChannelEditDetails.tsx
+++ b/package/src/components/ChannelDetails/components/ChannelEditDetailsFormContent.tsx
@@ -21,7 +21,7 @@ const selector = (state: EditChannelDetailsState) => ({
/**
* @experimental This component is experimental and is subject to change.
*/
-export const ChannelEditDetails = () => {
+export const ChannelEditDetailsFormContent = () => {
const { channel } = useChannelDetailsContext();
const { store } = useChannelEditDetailsContext();
const { ChannelEditImageSheet, ChannelEditName } = useComponentsContext();
diff --git a/package/src/components/ChannelDetails/components/ChannelEditDetailsFormHeader.tsx b/package/src/components/ChannelDetails/components/ChannelEditDetailsFormHeader.tsx
new file mode 100644
index 0000000000..5792886f9d
--- /dev/null
+++ b/package/src/components/ChannelDetails/components/ChannelEditDetailsFormHeader.tsx
@@ -0,0 +1,68 @@
+import React, { useState } from 'react';
+
+import { ActivityIndicator, Keyboard } from 'react-native';
+
+import { ModalHeader } from './modal/ModalHeader';
+
+import { useChannelEditDetailsContext } from '../../../contexts/channelEditDetailsContext/ChannelEditDetailsContext';
+import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext';
+import { useStableCallback } from '../../../hooks/useStableCallback';
+import { Checkmark } from '../../../icons/checkmark-1';
+import { useAreChannelDetailsEdited } from '../../../state-store/edit-channel-details-store';
+import { Button } from '../../ui/Button/Button';
+
+const loadingIconStyle = { margin: 0 };
+const LoadingButtonIcon = ({ height, width }: { height?: number; width?: number }) => (
+
+);
+
+export type ChannelEditDetailsFormHeaderProps = {
+ /** Called when the form is dismissed via the close button or after a successful save. */
+ onClose: () => void;
+};
+
+/**
+ * @experimental This component is experimental and is subject to change.
+ */
+export const ChannelEditDetailsFormHeader = ({ onClose }: ChannelEditDetailsFormHeaderProps) => {
+ const { store, submit } = useChannelEditDetailsContext();
+ const { t } = useTranslationContext();
+ const [saving, setSaving] = useState(false);
+ const channelDetailsEdited = useAreChannelDetailsEdited(store);
+ const confirmEnabled = channelDetailsEdited && !saving;
+
+ const handleConfirm = useStableCallback(async () => {
+ if (!confirmEnabled) return;
+ Keyboard.dismiss();
+ setSaving(true);
+ try {
+ await submit();
+ onClose();
+ } catch {
+ // failure notification already surfaced by the channel action
+ } finally {
+ setSaving(false);
+ }
+ });
+
+ return (
+
+ }
+ title={t('Edit')}
+ />
+ );
+};
diff --git a/package/src/components/ChannelDetails/components/ChannelEditDetailsModal.tsx b/package/src/components/ChannelDetails/components/ChannelEditDetailsModal.tsx
deleted file mode 100644
index 6d644e22c8..0000000000
--- a/package/src/components/ChannelDetails/components/ChannelEditDetailsModal.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-import React, { useState } from 'react';
-
-import { ActivityIndicator, Keyboard } from 'react-native';
-
-import { ChannelDetailsModal } from './modal/Modal';
-import { ModalHeader } from './modal/ModalHeader';
-
-import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext';
-import {
- ChannelEditDetailsProvider,
- useChannelEditDetailsContext,
-} from '../../../contexts/channelEditDetailsContext/ChannelEditDetailsContext';
-import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext';
-import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext';
-import { useChannelActions } from '../../../hooks/actions/useChannelActions';
-import { useStableCallback } from '../../../hooks/useStableCallback';
-import { Checkmark } from '../../../icons/checkmark-1';
-import {
- isImageDirty,
- isNameDirty,
- useIsImageDirty,
- useIsNameDirty,
-} from '../../../state-store/edit-channel-details-store';
-import type { File } from '../../../types/types';
-import { NotificationList } from '../../Notifications/NotificationList';
-import { NotificationTargetProvider } from '../../Notifications/NotificationTargetContext';
-import { Button } from '../../ui/Button/Button';
-
-const loadingIconStyle = { margin: 0 };
-const LoadingButtonIcon = ({ height, width }: { height?: number; width?: number }) => (
-
-);
-
-export type ChannelEditDetailsModalProps = {
- onClose: () => void;
- visible: boolean;
-};
-
-type ChannelEditDetailsModalContentProps = {
- onClose: () => void;
-};
-
-const ChannelEditDetailsModalBody = ({ onClose }: ChannelEditDetailsModalContentProps) => {
- const { channel, doFileUploadRequest } = useChannelDetailsContext();
- const { store } = useChannelEditDetailsContext();
- const { updateImage, updateName } = useChannelActions(channel);
- const { ChannelEditDetails } = useComponentsContext();
- const { t } = useTranslationContext();
- const [saving, setSaving] = useState(false);
- const nameDirty = useIsNameDirty(store);
- const imageDirty = useIsImageDirty(store);
- const confirmEnabled = (nameDirty || imageDirty) && !saving;
-
- const handleConfirm = useStableCallback(async () => {
- if (!confirmEnabled) return;
- Keyboard.dismiss();
- setSaving(true);
- try {
- const state = store.state.getLatestValue();
- const { currentName, updatedImage } = state;
- const nameDirty = isNameDirty(state);
- const imageDirty = isImageDirty(state);
- let nameOk = true;
- let imageOk = true;
- const tasks: Promise[] = [];
- if (nameDirty) {
- nameOk = false;
- tasks.push(
- updateName(currentName, {
- onSuccess: () => {
- nameOk = true;
- },
- }),
- );
- }
- if (imageDirty) {
- imageOk = false;
- tasks.push(
- updateImage(
- updatedImage as File | null,
- {
- onSuccess: () => {
- imageOk = true;
- },
- },
- doFileUploadRequest,
- ),
- );
- }
- await Promise.all(tasks);
- if (nameOk && imageOk) onClose();
- } finally {
- setSaving(false);
- }
- });
-
- return (
- <>
-
- }
- title={t('Edit')}
- />
-
-
- >
- );
-};
-
-/**
- * @experimental This component is experimental and is subject to change.
- */
-export const ChannelEditDetailsModalContent = ({
- onClose,
-}: ChannelEditDetailsModalContentProps) => {
- const { channel } = useChannelDetailsContext();
- const notificationHostId = channel?.cid ? `channel-edit-details:${channel.cid}` : undefined;
-
- if (!notificationHostId || !channel) {
- return null;
- }
-
- return (
-
-
-
-
-
- );
-};
-
-/**
- * @experimental This component is experimental and is subject to change.
- */
-export const ChannelEditDetailsModal = ({ onClose, visible }: ChannelEditDetailsModalProps) => (
-
-
-
-);
diff --git a/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentList.test.tsx b/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentList.test.tsx
index 11b99972b4..5d4b230ab2 100644
--- a/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentList.test.tsx
+++ b/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentList.test.tsx
@@ -81,7 +81,6 @@ jest.mock(
return children;
},
useChannelFileAttachmentListContext: () => ({
- channel: mockChannel,
searchSource: mockCurrentSearchSource,
}),
}),
@@ -135,7 +134,7 @@ const tree = (
diff --git a/package/src/components/ChannelDetails/components/__tests__/navigation-section/PinnedMessageList.test.tsx b/package/src/components/ChannelDetails/components/__tests__/navigation-section/PinnedMessageList.test.tsx
index 604f29ccec..27396f6b78 100644
--- a/package/src/components/ChannelDetails/components/__tests__/navigation-section/PinnedMessageList.test.tsx
+++ b/package/src/components/ChannelDetails/components/__tests__/navigation-section/PinnedMessageList.test.tsx
@@ -74,7 +74,6 @@ jest.mock(
return children;
},
useChannelPinnedMessageListContext: () => ({
- channel: mockChannel,
searchSource: mockCurrentSearchSource,
}),
}),
@@ -148,7 +147,7 @@ const tree = (searchSource: FakeSearchSource, props: { additionalFlatListProps?:
}}
>
diff --git a/package/src/components/ChannelDetails/components/index.ts b/package/src/components/ChannelDetails/components/index.ts
index 8eb88bebce..de20ebae43 100644
--- a/package/src/components/ChannelDetails/components/index.ts
+++ b/package/src/components/ChannelDetails/components/index.ts
@@ -5,8 +5,9 @@ export * from './ChannelDetailsNavigationSection';
export * from './ChannelDetailsProfile';
export * from './ChannelDetailsEditButton';
export * from './ChannelDetailsNavHeader';
-export * from './ChannelEditDetails';
-export * from './ChannelEditDetailsModal';
+export * from './ChannelEditDetailsForm';
+export * from './ChannelEditDetailsFormContent';
+export * from './ChannelEditDetailsFormHeader';
export * from './ChannelEditImageSheet';
export * from './ChannelEditName';
export * from './ChannelDetailsEditButton';
diff --git a/package/src/components/ChannelDetails/components/members/ChannelAddMembersButton.tsx b/package/src/components/ChannelDetails/components/members/ChannelAddMembersButton.tsx
new file mode 100644
index 0000000000..dfb71c2328
--- /dev/null
+++ b/package/src/components/ChannelDetails/components/members/ChannelAddMembersButton.tsx
@@ -0,0 +1,86 @@
+import React, { useCallback, useState } from 'react';
+
+import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext';
+import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext';
+import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext';
+import { useCanAddMembersToChannel } from '../../../../hooks/useCanAddMembersToChannel';
+import { UserAdd } from '../../../../icons/user-add';
+import { Button, ButtonProps } from '../../../ui/Button/Button';
+import { ChannelDetailsModal } from '../modal/Modal';
+
+export type ChannelAddMembersButtonProps = {
+ /** Override the default behavior, which opens the Add-members modal. */
+ onPress?: () => void;
+ /** Style forwarded to the underlying Button. */
+ style?: ButtonProps['style'];
+ testID?: string;
+ variant?: 'icon' | 'text';
+};
+
+/**
+ * @experimental This component is experimental and is subject to change.
+ */
+export const ChannelAddMembersButton = ({
+ onPress,
+ style,
+ testID,
+ variant = 'text',
+}: ChannelAddMembersButtonProps) => {
+ const { channel } = useChannelDetailsContext();
+ const { ChannelAddMembersForm } = useComponentsContext();
+ const { t } = useTranslationContext();
+ const isVisible = useCanAddMembersToChannel(channel);
+ const [isAddMembersVisible, setAddMembersVisible] = 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 modal, so we must not render the built-in one.
+ const usesDefaultModal = !onPress;
+
+ const handlePress = useCallback(() => {
+ if (onPress) {
+ onPress();
+ return;
+ }
+ setAddMembersVisible(true);
+ }, [onPress]);
+
+ const handleClose = useCallback(() => setAddMembersVisible(false), []);
+
+ if (!isVisible) {
+ return null;
+ }
+
+ return (
+ <>
+ {variant === 'icon' ? (
+
+ ) : (
+
+ )}
+ {usesDefaultModal ? (
+
+
+
+ ) : null}
+ >
+ );
+};
diff --git a/package/src/components/ChannelDetails/components/members/ChannelAddMembersForm.tsx b/package/src/components/ChannelDetails/components/members/ChannelAddMembersForm.tsx
new file mode 100644
index 0000000000..4f431e9a2e
--- /dev/null
+++ b/package/src/components/ChannelDetails/components/members/ChannelAddMembersForm.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+
+import type { UserSearchSource } from 'stream-chat';
+
+import { ChannelAddMembersProvider } from '../../../../contexts/channelAddMembersContext/ChannelAddMembersContext';
+import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext';
+import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext';
+import { NotificationList } from '../../../Notifications/NotificationList';
+
+export type ChannelAddMembersFormProps = {
+ /** Called when the form is dismissed via the close button or after members are added. */
+ onClose: () => void;
+ /**
+ * A custom `UserSearchSource` used to query and paginate the users to add.
+ * Overrides the source the provider creates by default (pre-configured to
+ * autocomplete by `name`).
+ */
+ searchSource?: UserSearchSource;
+};
+
+/**
+ * Renders the add-members form: a header with a confirm action, the user search
+ * list, and the notification list. Mount it wherever the add-members UI should
+ * appear (e.g. inside a modal or a navigation screen).
+ *
+ * @experimental This component is experimental and is subject to change.
+ */
+export const ChannelAddMembersForm = ({ onClose, searchSource }: ChannelAddMembersFormProps) => {
+ const { channel } = useChannelDetailsContext();
+ const { ChannelAddMembersFormContent, ChannelAddMembersFormHeader } = useComponentsContext();
+
+ if (!channel?.cid) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/package/src/components/ChannelDetails/components/members/ChannelAddMembers.tsx b/package/src/components/ChannelDetails/components/members/ChannelAddMembersFormContent.tsx
similarity index 96%
rename from package/src/components/ChannelDetails/components/members/ChannelAddMembers.tsx
rename to package/src/components/ChannelDetails/components/members/ChannelAddMembersFormContent.tsx
index 13fb201ec0..3e15a609c5 100644
--- a/package/src/components/ChannelDetails/components/members/ChannelAddMembers.tsx
+++ b/package/src/components/ChannelDetails/components/members/ChannelAddMembersFormContent.tsx
@@ -17,7 +17,7 @@ import { useNotificationApi } from '../../../Notifications/hooks/useNotification
import { EmptySearchResult } from '../../../UIComponents/EmptySearchResult';
import { SearchInput } from '../../../UIComponents/SearchInput';
-export type ChannelAddMembersProps = {
+export type ChannelAddMembersFormContentProps = {
/**
* Besides the existing default behavior of the user list, you can attach
* additional props to the underlying React Native FlatList.
@@ -41,7 +41,9 @@ const listStateSelector = (state: SearchSourceState) => {
/**
* @experimental This component is experimental and is subject to change.
*/
-export const ChannelAddMembers = ({ additionalFlatListProps }: ChannelAddMembersProps) => {
+export const ChannelAddMembersFormContent = ({
+ additionalFlatListProps,
+}: ChannelAddMembersFormContentProps) => {
const { t } = useTranslationContext();
const styles = useStyles();
const {
diff --git a/package/src/components/ChannelDetails/components/members/ChannelAddMembersFormHeader.tsx b/package/src/components/ChannelDetails/components/members/ChannelAddMembersFormHeader.tsx
new file mode 100644
index 0000000000..486170014d
--- /dev/null
+++ b/package/src/components/ChannelDetails/components/members/ChannelAddMembersFormHeader.tsx
@@ -0,0 +1,78 @@
+import React, { useCallback, useState } from 'react';
+
+import { ActivityIndicator } from 'react-native';
+
+import { useChannelAddMembersContext } from '../../../../contexts/channelAddMembersContext/ChannelAddMembersContext';
+import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
+import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext';
+import { useStableCallback } from '../../../../hooks/useStableCallback';
+import { Checkmark } from '../../../../icons/checkmark-1';
+import { useIsSelectionEmpty } from '../../../../state-store/selection-store';
+import { Button } from '../../../ui/Button/Button';
+import { ModalHeader } from '../modal/ModalHeader';
+
+const loadingIconStyle = { margin: 0 };
+const LoadingButtonIcon = ({ height, width }: { height?: number; width?: number }) => (
+
+);
+
+export type ChannelAddMembersFormHeaderProps = {
+ /** Called when the form is dismissed via the close button or after members are added. */
+ onClose: () => void;
+};
+
+/**
+ * @experimental This component is experimental and is subject to change.
+ */
+export const ChannelAddMembersFormHeader = ({ onClose }: ChannelAddMembersFormHeaderProps) => {
+ const { selectionStore, submit } = useChannelAddMembersContext();
+ const { t } = useTranslationContext();
+ const {
+ theme: {
+ channelDetails: {
+ memberSection: { confirmButton: confirmButtonOverride },
+ },
+ },
+ } = useTheme();
+ const isSelectionEmpty = useIsSelectionEmpty(selectionStore);
+ const [addingMembers, setAddingMembers] = useState(false);
+ const confirmEnabled = !isSelectionEmpty && !addingMembers;
+
+ const handleClose = useCallback(() => {
+ onClose();
+ }, [onClose]);
+
+ const handleConfirm = useStableCallback(async () => {
+ setAddingMembers(true);
+ try {
+ await submit();
+ onClose();
+ } catch {
+ // failure notification already surfaced by the channel action
+ } finally {
+ setAddingMembers(false);
+ }
+ });
+
+ return (
+
+ }
+ title={t('Add Members')}
+ />
+ );
+};
diff --git a/package/src/components/ChannelDetails/components/members/ChannelAddMembersModal.tsx b/package/src/components/ChannelDetails/components/members/ChannelAddMembersModal.tsx
deleted file mode 100644
index 48c47b8972..0000000000
--- a/package/src/components/ChannelDetails/components/members/ChannelAddMembersModal.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-import React, { useCallback, useState } from 'react';
-
-import { ActivityIndicator } from 'react-native';
-
-import {
- ChannelAddMembersProvider,
- useChannelAddMembersContext,
-} from '../../../../contexts/channelAddMembersContext/ChannelAddMembersContext';
-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 { useChannelActions } from '../../../../hooks/actions/useChannelActions';
-import { useStableCallback } from '../../../../hooks/useStableCallback';
-import { Checkmark } from '../../../../icons/checkmark-1';
-import { useIsSelectionEmpty } from '../../../../state-store/selection-store';
-import { NotificationList } from '../../../Notifications/NotificationList';
-import { NotificationTargetProvider } from '../../../Notifications/NotificationTargetContext';
-import { Button } from '../../../ui/Button/Button';
-import { ChannelDetailsModal } from '../modal/Modal';
-import { ModalHeader } from '../modal/ModalHeader';
-
-const loadingIconStyle = { margin: 0 };
-const LoadingButtonIcon = ({ height, width }: { height?: number; width?: number }) => (
-
-);
-
-export type ChannelAddMembersModalProps = {
- onClose: () => void;
- visible: boolean;
-};
-
-type ChannelAddMembersModalContentProps = {
- onClose: () => void;
-};
-
-/**
- * @experimental This component is experimental and is subject to change.
- */
-export const ChannelAddMembersModalContent = ({ onClose }: ChannelAddMembersModalContentProps) => {
- const { channel } = useChannelDetailsContext();
- const notificationHostId = channel?.cid ? `channel-add-members:${channel.cid}` : undefined;
-
- if (!notificationHostId) {
- return null;
- }
-
- return (
-
-
-
-
-
- );
-};
-
-const ChannelAddMembersModalBody = ({ onClose }: ChannelAddMembersModalContentProps) => {
- const { channel } = useChannelDetailsContext();
- const { addMembers } = useChannelActions(channel);
- const { ChannelAddMembers } = useComponentsContext();
- const { selectionStore } = useChannelAddMembersContext();
- const { t } = useTranslationContext();
- const {
- theme: {
- channelDetails: {
- memberSection: { confirmButton: confirmButtonOverride },
- },
- },
- } = useTheme();
- const isSelectionEmpty = useIsSelectionEmpty(selectionStore);
- const [addingMembers, setAddingMembers] = useState(false);
- const confirmEnabled = !isSelectionEmpty && !addingMembers;
-
- const handleClose = useCallback(() => {
- onClose();
- }, [onClose]);
-
- const handleConfirm = useStableCallback(async () => {
- setAddingMembers(true);
- try {
- const ids = Array.from(selectionStore.state.getLatestValue().selectedIds);
- await addMembers(ids, {
- onSuccess: () => {
- onClose();
- },
- });
- } finally {
- setAddingMembers(false);
- }
- });
-
- return (
- <>
-
- }
- title={t('Add Members')}
- />
-
-
- >
- );
-};
-
-/**
- * @experimental This component is experimental and is subject to change.
- */
-export const ChannelAddMembersModal = ({ onClose, visible }: ChannelAddMembersModalProps) => (
-
-
-
-);
diff --git a/package/src/components/ChannelDetails/components/members/ChannelAllMembersModal.tsx b/package/src/components/ChannelDetails/components/members/ChannelAllMembersModal.tsx
deleted file mode 100644
index 3c2eb817c3..0000000000
--- a/package/src/components/ChannelDetails/components/members/ChannelAllMembersModal.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import React from 'react';
-
-import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext';
-import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext';
-import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext';
-import { useChannelMemberCount } from '../../../../hooks';
-import { useChannelOwnCapabilities } from '../../../../hooks/useChannelOwnCapabilities';
-import { UserAdd } from '../../../../icons/user-add';
-import { Button } from '../../../ui/Button/Button';
-import { ChannelDetailsModal } from '../modal/Modal';
-import { ModalHeader } from '../modal/ModalHeader';
-
-export type ChannelAllMembersModalProps = {
- onAddMembersPress: () => void;
- onClose: () => void;
- visible: boolean;
-};
-
-type ChannelAllMembersModalContentProps = Omit;
-
-/**
- * @experimental This component is experimental and is subject to change.
- */
-export const ChannelAllMembersModalContent = ({
- onAddMembersPress,
- onClose,
-}: ChannelAllMembersModalContentProps) => {
- const { channel } = useChannelDetailsContext();
- const { ChannelMemberList } = useComponentsContext();
- const { t } = useTranslationContext();
- const ownCapabilities = useChannelOwnCapabilities(channel);
- const updateChannelMembers = ownCapabilities?.includes('update-channel-members') ?? false;
- const total = useChannelMemberCount(channel);
-
- return (
- <>
-
- ) : null
- }
- title={t('{{count}} members', { count: total })}
- />
-
- >
- );
-};
-
-/**
- * @experimental This component is experimental and is subject to change.
- */
-export const ChannelAllMembersModal = ({
- onAddMembersPress,
- onClose,
- visible,
-}: ChannelAllMembersModalProps) => (
-
-
-
-);
diff --git a/package/src/components/ChannelDetails/components/members/ChannelMemberActionsSheet.tsx b/package/src/components/ChannelDetails/components/members/ChannelMemberActionsSheet.tsx
index 608be3147c..62ca30a849 100644
--- a/package/src/components/ChannelDetails/components/members/ChannelMemberActionsSheet.tsx
+++ b/package/src/components/ChannelDetails/components/members/ChannelMemberActionsSheet.tsx
@@ -8,6 +8,7 @@ import { useComponentsContext } from '../../../../contexts/componentsContext/Com
import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
import {
ChannelMemberActionItem,
+ GetChannelMemberActionItems,
useChannelMemberActionItems,
} from '../../../../hooks/actions/useChannelMemberActionItems';
import { BottomSheetModal } from '../../../UIComponents/BottomSheetModal';
@@ -17,15 +18,24 @@ export type ChannelMemberActionsSheetProps = {
member: ChannelMemberResponse;
onClose: () => void;
visible: boolean;
+ /**
+ * Customize the list of action items rendered in the per-member actions bottom sheet.
+ *
+ * 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;
};
const keyExtractor = (item: ChannelMemberActionItem) => item.id;
const ChannelMemberActionsSheetInner = ({
+ getChannelMemberActionItems,
member,
onClose,
}: Omit) => {
- const { channel, getChannelMemberActionItems } = useChannelDetailsContext();
+ const { channel } = useChannelDetailsContext();
const { ChannelDetailsActionItem, ChannelMemberItem } = useComponentsContext();
const {
theme: {
@@ -83,12 +93,17 @@ const ChannelMemberActionsSheetInner = ({
* @experimental This component is experimental and is subject to change.
*/
export const ChannelMemberActionsSheet = ({
+ getChannelMemberActionItems,
member,
onClose,
visible,
}: ChannelMemberActionsSheetProps) => (
-
+
);
diff --git a/package/src/components/ChannelDetails/components/members/ChannelMemberItem.tsx b/package/src/components/ChannelDetails/components/members/ChannelMemberItem.tsx
index 7d0c9f3adc..27ffa7f5ff 100644
--- a/package/src/components/ChannelDetails/components/members/ChannelMemberItem.tsx
+++ b/package/src/components/ChannelDetails/components/members/ChannelMemberItem.tsx
@@ -11,13 +11,19 @@ import { Mute } from '../../../../icons';
import { primitives } from '../../../../theme';
import { useUserMuteActive } from '../../../Message/hooks/useUserMuteActive';
import { UserAvatar } from '../../../ui/Avatar/UserAvatar';
-import { useMemberRoleLabel } from '../../hooks/members/useMemberRoleLabel';
+import { GetMemberRoleLabel, useMemberRoleLabel } from '../../hooks/members/useMemberRoleLabel';
import { useUserActivityStatus } from '../../hooks/useUserActivityStatus';
export type ChannelMemberItemSize = 'sm' | 'lg';
export type ChannelMemberItemProps = {
member: ChannelMemberResponse;
+ /**
+ * Override the role label shown next to the member. Receives `{ channel, member, t }` and
+ * returns the label (or `null`/`undefined` to render none). Defaults to the built-in
+ * Owner > Admin > Moderator logic.
+ */
+ getMemberRoleLabel?: GetMemberRoleLabel;
onPress?: (member: ChannelMemberResponse) => void;
/**
* Visual size of the row.
@@ -33,6 +39,7 @@ export type ChannelMemberItemProps = {
* @experimental This component is experimental and is subject to change.
*/
export const ChannelMemberItem = ({
+ getMemberRoleLabel,
member,
onPress,
size = 'sm',
@@ -55,7 +62,7 @@ export const ChannelMemberItem = ({
} = useTheme();
const styles = useStyles();
const statusLine = useUserActivityStatus(member.user);
- const roleLabel = useMemberRoleLabel(member);
+ const roleLabel = useMemberRoleLabel(member, getMemberRoleLabel);
const isMuted = useUserMuteActive(member.user);
const user = member.user;
@@ -171,7 +178,7 @@ const useStyles = () => {
flexDirection: 'row',
gap: primitives.spacingSm,
minHeight: 48,
- paddingHorizontal: primitives.spacingMd,
+ paddingHorizontal: primitives.spacingSm,
paddingVertical: primitives.spacingXs,
},
containerLarge: {
diff --git a/package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx b/package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx
index fbf98f2d2b..146951a6fe 100644
--- a/package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx
+++ b/package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx
@@ -1,7 +1,11 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, FlatList, type FlatListProps, StyleSheet, View } from 'react-native';
-import type { ChannelMemberResponse, SearchSourceState } from 'stream-chat';
+import type {
+ ChannelMemberResponse,
+ ChannelMemberSearchSource,
+ SearchSourceState,
+} from 'stream-chat';
import { MemberListLoadingSkeleton } from './MemberListLoadingSkeleton';
@@ -14,6 +18,7 @@ import { useComponentsContext } from '../../../../contexts/componentsContext/Com
import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext';
import { getNotificationErrorOptions } from '../../../../hooks/actions/useChannelActions';
+import { useChannelMemberCount } from '../../../../hooks/useChannelMemberCount';
import { useStateStore } from '../../../../hooks/useStateStore';
import { primitives } from '../../../../theme';
import { useNotificationApi } from '../../../Notifications/hooks/useNotificationApi';
@@ -41,6 +46,12 @@ export type ChannelMemberListProps = {
*/
additionalFlatListProps?: Partial>;
searchInputProps?: SearchInputProps;
+ /**
+ * A custom `ChannelMemberSearchSource` used to query and paginate the member
+ * list. Overrides the source the provider creates by default (pre-configured
+ * to autocomplete by `name`).
+ */
+ searchSource?: ChannelMemberSearchSource;
};
const ChannelMemberListContent = ({
@@ -53,11 +64,11 @@ const ChannelMemberListContent = ({
channelDetails: { memberList },
},
} = useTheme();
- const { onMemberPress } = useChannelDetailsContext();
const { ChannelMemberActionsSheet, ChannelMemberItem } = useComponentsContext();
const { addNotification } = useNotificationApi();
- const { channel, searchSource } = useChannelMemberListContext();
+ const { channel } = useChannelDetailsContext();
+ const { searchSource } = useChannelMemberListContext();
const { error, hasNext, loading, members, searchQuery } = useStateStore(
searchSource.state,
listStateSelector,
@@ -65,13 +76,16 @@ const ChannelMemberListContent = ({
const [selectedMember, setSelectedMember] = useState(null);
- const initialized = useRef(false);
+ const memberCount = useChannelMemberCount(channel);
+
+ // Fetch members on mount and when the member count changes
+ // Member count changes used when add member modal is opened above member list modal
useEffect(() => {
- if (!initialized.current) {
- initialized.current = true;
+ if (!searchSource.state.getLatestValue().searchQuery) {
+ searchSource.resetStateAndActivate();
searchSource.search();
}
- }, [searchSource]);
+ }, [memberCount, searchSource]);
useEffect(() => {
if (!error) {
@@ -91,14 +105,8 @@ const ChannelMemberListContent = ({
const handleMemberActionsClose = useCallback(() => setSelectedMember(null), []);
const handleMemberPress = useCallback(
- (member: ChannelMemberResponse) => {
- if (onMemberPress) {
- onMemberPress(member);
- return;
- }
- setSelectedMember(member);
- },
- [onMemberPress],
+ (member: ChannelMemberResponse) => setSelectedMember(member),
+ [],
);
const renderItem = useCallback(
@@ -166,7 +174,7 @@ const ChannelMemberListContent = ({
* Lists all channel members with the ability to search them.
* @experimental This component is experimental and is subject to change.
*/
-export const ChannelMemberList = (props: ChannelMemberListProps = {}) => {
+export const ChannelMemberList = ({ searchSource, ...props }: ChannelMemberListProps = {}) => {
const { channel } = useChannelDetailsContext();
const notificationHostId = channel?.cid ? `channel-member-list:${channel.cid}` : undefined;
@@ -175,7 +183,7 @@ export const ChannelMemberList = (props: ChannelMemberListProps = {}) => {
}
return (
-
+
diff --git a/package/src/components/ChannelDetails/components/members/index.ts b/package/src/components/ChannelDetails/components/members/index.ts
index a5d516144d..1bf93682ad 100644
--- a/package/src/components/ChannelDetails/components/members/index.ts
+++ b/package/src/components/ChannelDetails/components/members/index.ts
@@ -1,7 +1,8 @@
export * from './AddMemberSearchResultItem';
-export * from './ChannelAddMembers';
-export * from './ChannelAddMembersModal';
-export * from './ChannelAllMembersModal';
+export * from './ChannelAddMembersButton';
+export * from './ChannelAddMembersForm';
+export * from './ChannelAddMembersFormContent';
+export * from './ChannelAddMembersFormHeader';
export * from './ChannelMemberActionsSheet';
export * from './ChannelMemberItem';
export * from './ChannelMemberList';
diff --git a/package/src/components/ChannelDetails/components/modal/Modal.tsx b/package/src/components/ChannelDetails/components/modal/Modal.tsx
index e1393307c0..4d810b8923 100644
--- a/package/src/components/ChannelDetails/components/modal/Modal.tsx
+++ b/package/src/components/ChannelDetails/components/modal/Modal.tsx
@@ -1,9 +1,10 @@
-import React, { useMemo } from 'react';
-import { Modal } from 'react-native';
+import React, { useEffect, useMemo } from 'react';
+import { Modal, Platform } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext';
import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
type ChannelDetailsModalProps = {
@@ -31,6 +32,19 @@ export const ChannelDetailsModal = ({
} = useTheme();
const styles = useStyles();
const { top } = useSafeAreaInsets();
+ const { signalStore } = useChannelDetailsContext();
+
+ useEffect(() => {
+ const unsubscribe = signalStore.state.subscribeWithSelector(
+ (state) => ({ signal: state.signal }),
+ ({ signal }) => {
+ if (signal) {
+ onClose();
+ }
+ },
+ );
+ return unsubscribe;
+ }, [signalStore, onClose]);
return (
diff --git a/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentList.tsx b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentList.tsx
index 9950a787cb..7c0731d3e0 100644
--- a/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentList.tsx
+++ b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentList.tsx
@@ -7,7 +7,7 @@ import {
View,
} from 'react-native';
-import type { MessageResponse, SearchSourceState } from 'stream-chat';
+import type { MessageResponse, MessageSearchSource, SearchSourceState } from 'stream-chat';
import { FileAttachmentListLoadingSkeleton } from './FileAttachmentListLoadingSkeleton';
import { FileAttachmentListSectionHeader } from './FileAttachmentListSectionHeader';
@@ -42,6 +42,12 @@ export type FileAttachmentListProps = {
* See https://reactnative.dev/docs/sectionlist#props for the full list.
*/
additionalSectionListProps?: Partial>;
+ /**
+ * A custom `MessageSearchSource` used to query and paginate the file/audio
+ * attachments. Overrides the source the provider creates by default
+ * (pre-configured to fetch file/audio attachments, newest first).
+ */
+ searchSource?: MessageSearchSource;
};
const keyExtractor = (item: FileAttachmentTile, index: number) => `${item.message.id}-${index}`;
@@ -65,7 +71,8 @@ const FileAttachmentListContent = ({ additionalSectionListProps }: FileAttachmen
const { addNotification } = useNotificationApi();
- const { channel, searchSource } = useChannelFileAttachmentListContext();
+ const { channel } = useChannelDetailsContext();
+ const { searchSource } = useChannelFileAttachmentListContext();
const { error, hasNext, loading, messages } = useStateStore(
searchSource.state,
listStateSelector,
@@ -167,7 +174,7 @@ const FileAttachmentListContent = ({ additionalSectionListProps }: FileAttachmen
/**
* @experimental This component is experimental and is subject to change.
*/
-export const FileAttachmentList = (props: FileAttachmentListProps) => {
+export const FileAttachmentList = ({ searchSource, ...props }: FileAttachmentListProps) => {
const { channel } = useChannelDetailsContext();
const notificationHostId = channel?.cid ? `file-attachment-list:${channel.cid}` : undefined;
@@ -176,7 +183,7 @@ export const FileAttachmentList = (props: FileAttachmentListProps) => {
}
return (
-
+
diff --git a/package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx b/package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx
index dd4c69bc58..0dea67ec01 100644
--- a/package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx
+++ b/package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx
@@ -8,7 +8,12 @@ import {
View,
} from 'react-native';
-import { formatMessage, type MessageResponse, type SearchSourceState } from 'stream-chat';
+import {
+ formatMessage,
+ type MessageResponse,
+ type MessageSearchSource,
+ type SearchSourceState,
+} from 'stream-chat';
import { type MediaItemPressParams } from './MediaItem';
import { MediaListLoadingSkeleton } from './MediaListLoadingSkeleton';
@@ -48,6 +53,12 @@ export type MediaListProps = {
* See https://reactnative.dev/docs/flatlist#props for the full list.
*/
additionalFlatListProps?: Partial>;
+ /**
+ * A custom `MessageSearchSource` used to query and paginate the media grid.
+ * Overrides the source the provider creates by default (pre-configured to
+ * fetch image/video attachments, newest first).
+ */
+ searchSource?: MessageSearchSource;
};
const keyExtractor = (item: MediaTile, index: number) => `${item.message.id}-${index}`;
@@ -72,7 +83,8 @@ const MediaListContent = ({ additionalFlatListProps }: MediaListProps) => {
const { addNotification } = useNotificationApi();
- const { channel, searchSource } = useChannelMediaListContext();
+ const { channel } = useChannelDetailsContext();
+ const { searchSource } = useChannelMediaListContext();
const { imageGalleryStateStore } = useImageGalleryContext();
const { setOverlay } = useOverlayContext();
const { error, hasNext, loading, messages } = useStateStore(
@@ -204,7 +216,7 @@ const MediaListContent = ({ additionalFlatListProps }: MediaListProps) => {
/**
* @experimental This component is experimental and is subject to change.
*/
-export const MediaList = (props: MediaListProps) => {
+export const MediaList = ({ searchSource, ...props }: MediaListProps) => {
const { channel } = useChannelDetailsContext();
const notificationHostId = channel?.cid ? `media-list:${channel.cid}` : undefined;
@@ -213,7 +225,7 @@ export const MediaList = (props: MediaListProps) => {
}
return (
-
+
diff --git a/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx b/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx
index 069a050ba1..8f481f5339 100644
--- a/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx
+++ b/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, FlatList, type FlatListProps, StyleSheet, View } from 'react-native';
-import type { MessageResponse, SearchSourceState } from 'stream-chat';
+import type { MessageResponse, MessageSearchSource, SearchSourceState } from 'stream-chat';
import { PinnedMessageListLoadingSkeleton } from './PinnedMessageListLoadingSkeleton';
@@ -33,6 +33,12 @@ export type PinnedMessageListProps = {
*/
additionalFlatListProps?: Partial>;
searchInputProps?: SearchInputProps;
+ /**
+ * A custom `MessageSearchSource` used to query and paginate the pinned
+ * messages. Overrides the source the provider creates by default
+ * (pre-configured to fetch pinned messages and search by `text`).
+ */
+ searchSource?: MessageSearchSource;
};
const keyExtractor = (message: MessageResponse) => message.id;
@@ -60,7 +66,8 @@ const PinnedMessageListContent = ({
const { addNotification } = useNotificationApi();
- const { channel, searchSource } = useChannelPinnedMessageListContext();
+ const { channel } = useChannelDetailsContext();
+ const { searchSource } = useChannelPinnedMessageListContext();
const { error, hasNext, loading, messages, searchQuery } = useStateStore(
searchSource.state,
listStateSelector,
@@ -161,7 +168,7 @@ const PinnedMessageListContent = ({
/**
* @experimental This component is experimental and is subject to change.
*/
-export const PinnedMessageList = (props: PinnedMessageListProps) => {
+export const PinnedMessageList = ({ searchSource, ...props }: PinnedMessageListProps) => {
const { channel } = useChannelDetailsContext();
const notificationHostId = channel?.cid ? `pinned-message-list:${channel.cid}` : undefined;
@@ -170,7 +177,7 @@ export const PinnedMessageList = (props: PinnedMessageListProps) => {
}
return (
-
+
diff --git a/package/src/components/ChannelDetails/hooks/members/useMemberRoleLabel.ts b/package/src/components/ChannelDetails/hooks/members/useMemberRoleLabel.ts
index c277fbb351..f066debe80 100644
--- a/package/src/components/ChannelDetails/hooks/members/useMemberRoleLabel.ts
+++ b/package/src/components/ChannelDetails/hooks/members/useMemberRoleLabel.ts
@@ -1,17 +1,34 @@
-import type { ChannelMemberResponse } from 'stream-chat';
+import type { Channel, ChannelMemberResponse } from 'stream-chat';
import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext';
+import type { TranslationContextValue } from '../../../../contexts/translationContext/TranslationContext';
import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext';
+/**
+ * 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.
+ */
+export type GetMemberRoleLabel = (params: {
+ channel: Channel;
+ member: ChannelMemberResponse;
+ t: TranslationContextValue['t'];
+}) => string | null | undefined;
+
/**
* Resolves the trailing role label for a channel member row in the channel details screen.
*
* Priority — Owner > Admin > Moderator. When a member matches none of the rules
- * (and no custom `getMemberRoleLabel` is provided on the screen), returns `null`.
+ * (and no custom `getMemberRoleLabel` is provided), returns `null`.
* @experimental This hook is experimental and is subject to change.
*/
-export const useMemberRoleLabel = (member: ChannelMemberResponse): string | null => {
- const { channel, getMemberRoleLabel } = useChannelDetailsContext();
+export const useMemberRoleLabel = (
+ member: ChannelMemberResponse,
+ getMemberRoleLabel?: GetMemberRoleLabel,
+): string | null => {
+ const { channel } = useChannelDetailsContext();
const { t } = useTranslationContext();
if (getMemberRoleLabel) {
diff --git a/package/src/components/ChannelDetails/hooks/useChannelDetailsActionItems.ts b/package/src/components/ChannelDetails/hooks/useChannelDetailsActionItems.ts
index 93420823d7..09e020cf3c 100644
--- a/package/src/components/ChannelDetails/hooks/useChannelDetailsActionItems.ts
+++ b/package/src/components/ChannelDetails/hooks/useChannelDetailsActionItems.ts
@@ -3,14 +3,21 @@ import { useMemo } from 'react';
import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext';
import {
ChannelActionItem,
+ GetChannelActionItems,
useChannelActionItems,
} from '../../../hooks/actions/useChannelActionItems';
/**
* @experimental This hook is experimental and is subject to change.
*/
-export const useChannelDetailsActionItems = (): ChannelActionItem[] => {
- const { channel, getChannelActionItems, onChannelDismiss } = useChannelDetailsContext();
+export const useChannelDetailsActionItems = ({
+ getChannelActionItems,
+ onChannelDismiss,
+}: {
+ getChannelActionItems?: GetChannelActionItems;
+ onChannelDismiss?: () => void;
+} = {}): ChannelActionItem[] => {
+ const { channel } = useChannelDetailsContext();
const items = useChannelActionItems({ channel, getChannelActionItems, surface: 'details' });
diff --git a/package/src/components/ChannelDetails/hooks/useChannelDetailsNavigationItems.ts b/package/src/components/ChannelDetails/hooks/useChannelDetailsNavigationItems.ts
index 2d7d871b86..9017c17045 100644
--- a/package/src/components/ChannelDetails/hooks/useChannelDetailsNavigationItems.ts
+++ b/package/src/components/ChannelDetails/hooks/useChannelDetailsNavigationItems.ts
@@ -1,6 +1,5 @@
import React, { useMemo } from 'react';
-import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext';
import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext';
import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext';
import { Picture } from '../../../icons';
@@ -86,9 +85,12 @@ export const getChannelDetailsNavigationItems: GetChannelDetailsNavigationItems
*
* @experimental This hook is experimental and is subject to change.
*/
-export const useChannelDetailsNavigationItems = (): ChannelDetailsNavigationItem[] => {
+export const useChannelDetailsNavigationItems = ({
+ getNavigationItems = getChannelDetailsNavigationItems,
+}: {
+ getNavigationItems?: GetChannelDetailsNavigationItems;
+} = {}): ChannelDetailsNavigationItem[] => {
const { t } = useTranslationContext();
- const { getNavigationItems = getChannelDetailsNavigationItems } = useChannelDetailsContext();
const context = useMemo(() => ({ t }), [t]);
diff --git a/package/src/components/ChannelDetails/hooks/useEditChannelImage.ts b/package/src/components/ChannelDetails/hooks/useEditChannelImage.ts
index a792b495ca..d35eeaf41c 100644
--- a/package/src/components/ChannelDetails/hooks/useEditChannelImage.ts
+++ b/package/src/components/ChannelDetails/hooks/useEditChannelImage.ts
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { Alert, Linking } from 'react-native';
-import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext';
+import { useChannelEditDetailsContext } from '../../../contexts/channelEditDetailsContext/ChannelEditDetailsContext';
import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext';
import { NativeHandlers } from '../../../native';
import type { File } from '../../../types/types';
@@ -31,11 +31,11 @@ export type UseEditChannelImageResult = {
* hook intentionally does NOT upload the picked file; the consumer receives a
* `File` and decides what to do with it.
*
- * Reads `compressImageQuality` from `ChannelDetailsContext`.
+ * Reads `compressImageQuality` from `ChannelEditDetailsContext`.
* @experimental This hook is experimental and is subject to change.
*/
export const useEditChannelImage = (): UseEditChannelImageResult => {
- const { compressImageQuality } = useChannelDetailsContext();
+ const { compressImageQuality } = useChannelEditDetailsContext();
const { t } = useTranslationContext();
const takePhoto = useCallback(async (): Promise => {
diff --git a/package/src/contexts/channelAddMembersContext/ChannelAddMembersContext.tsx b/package/src/contexts/channelAddMembersContext/ChannelAddMembersContext.tsx
index 20a6b5b7b1..b269b9419e 100644
--- a/package/src/contexts/channelAddMembersContext/ChannelAddMembersContext.tsx
+++ b/package/src/contexts/channelAddMembersContext/ChannelAddMembersContext.tsx
@@ -1,9 +1,13 @@
-import React, { PropsWithChildren, useContext, useState } from 'react';
+import React, { PropsWithChildren, useContext, useMemo, useState } from 'react';
import { UserSearchSource } from 'stream-chat';
import { useChatContext } from '..';
+import { NotificationTargetProvider } from '../../components/Notifications/NotificationTargetContext';
+import { useChannelActions } from '../../hooks/actions/useChannelActions';
+import { useStableCallback } from '../../hooks/useStableCallback';
import { SelectionStore } from '../../state-store/selection-store';
+import { useChannelDetailsContext } from '../channelDetailsContext/channelDetailsContext';
import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue';
import { isTestEnvironment } from '../utils/isTestEnvironment';
@@ -13,19 +17,49 @@ import { isTestEnvironment } from '../utils/isTestEnvironment';
export type ChannelAddMembersContextValue = {
selectionStore: SelectionStore;
searchSource: UserSearchSource;
+ /**
+ * Adds the currently selected members to the channel. Resolves on success and
+ * rejects if the request fails.
+ */
+ submit: () => Promise;
};
+/**
+ * Props for the {@link ChannelAddMembersProvider} that seed the add-members
+ * configuration into the context.
+ */
+export type ChannelAddMembersProviderProps = PropsWithChildren<{
+ /**
+ * A custom `UserSearchSource` used to query and paginate the users to add.
+ * Overrides the source the provider creates by default (pre-configured to
+ * autocomplete by `name`). The provider uses it as-is, so the consumer owns
+ * its activation/sorting.
+ */
+ searchSource?: UserSearchSource;
+}>;
+
export const ChannelAddMembersContext = React.createContext(
DEFAULT_BASE_CONTEXT_VALUE as ChannelAddMembersContextValue,
);
/**
- * @experimental This API is experimental and is subject to change.
+ * Builds the {@link ChannelAddMembersContextValue}. Rendered inside the
+ * {@link NotificationTargetProvider} so that notifications emitted by `submit`
+ * (via {@link useChannelActions}) resolve to the add-members host.
*/
-export const ChannelAddMembersProvider = ({ children }: PropsWithChildren) => {
+const ChannelAddMembersContextProviderInner = ({
+ children,
+ searchSource: searchSourceProp,
+}: ChannelAddMembersProviderProps) => {
const { client } = useChatContext();
+ const { channel } = useChannelDetailsContext();
+ const { addMembers } = useChannelActions(channel);
const [selectionStore] = useState(() => new SelectionStore());
const [searchSource] = useState(() => {
+ // A custom source is used as-is; the consumer owns its activation/sorting.
+ if (searchSourceProp) {
+ return searchSourceProp;
+ }
const source = new UserSearchSource(
client,
{ pageSize: 25, allowEmptySearchString: true, resetOnNewSearchQuery: false },
@@ -48,10 +82,49 @@ export const ChannelAddMembersProvider = ({ children }: PropsWithChildren {
+ const ids = Array.from(selectionStore.state.getLatestValue().selectedIds);
+ let failed = false;
+ let firstError: unknown;
+ await addMembers(ids, {
+ onFailure: (error) => {
+ failed = true;
+ firstError = error;
+ },
+ });
+ if (failed) {
+ throw firstError;
+ }
+ });
+
+ const value = useMemo(
+ () => ({ selectionStore, searchSource, submit }),
+ [selectionStore, searchSource, submit],
+ );
+
+ return (
+ {children}
+ );
+};
+
+/**
+ * @experimental This API is experimental and is subject to change.
+ */
+export const ChannelAddMembersProvider = ({
+ children,
+ searchSource,
+}: ChannelAddMembersProviderProps) => {
+ const { channel } = useChannelDetailsContext();
+
return (
-
- {children}
-
+
+
+ {children}
+
+
);
};
diff --git a/package/src/contexts/channelDetailsContext/channelDetailsContext.tsx b/package/src/contexts/channelDetailsContext/channelDetailsContext.tsx
index 2e741cc0a7..2876ab0998 100644
--- a/package/src/contexts/channelDetailsContext/channelDetailsContext.tsx
+++ b/package/src/contexts/channelDetailsContext/channelDetailsContext.tsx
@@ -1,14 +1,22 @@
-import React, { PropsWithChildren, useContext } from 'react';
+import React, { PropsWithChildren, useCallback, useContext, useMemo, useState } from 'react';
-import { ChannelDetailsProps } from '../../components';
+import type { Channel } from 'stream-chat';
+import { SignalStore } from '../../state-store/signal-store';
import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue';
import { isTestEnvironment } from '../utils/isTestEnvironment';
/**
* @experimental This API is experimental and is subject to change.
*/
-export type ChannelDetailsContextValue = ChannelDetailsProps;
+export type ChannelDetailsContextValue = {
+ channel: Channel;
+ /**
+ * Signals all ChannelDetails modals to close themselves.
+ */
+ closeModals: () => void;
+ signalStore: SignalStore;
+};
export const ChannelDetailsContext = React.createContext(
DEFAULT_BASE_CONTEXT_VALUE as ChannelDetailsContextValue,
);
@@ -17,15 +25,21 @@ export const ChannelDetailsContext = React.createContext(
* @experimental This API is experimental and is subject to change.
*/
export const ChannelDetailsContextProvider = ({
+ channel,
children,
- value,
}: PropsWithChildren<{
- value: ChannelDetailsContextValue;
-}>) => (
-
- {children}
-
-);
+ channel: Channel;
+}>) => {
+ const [signalStore] = useState(() => new SignalStore());
+ const closeModals = useCallback(() => signalStore.signal(), [signalStore]);
+
+ const value = useMemo(
+ () => ({ channel, closeModals, signalStore }),
+ [channel, closeModals, signalStore],
+ );
+
+ return {children};
+};
/**
* @experimental This API is experimental and is subject to change.
diff --git a/package/src/contexts/channelEditDetailsContext/ChannelEditDetailsContext.tsx b/package/src/contexts/channelEditDetailsContext/ChannelEditDetailsContext.tsx
index 079df04e30..e8c6d2f217 100644
--- a/package/src/contexts/channelEditDetailsContext/ChannelEditDetailsContext.tsx
+++ b/package/src/contexts/channelEditDetailsContext/ChannelEditDetailsContext.tsx
@@ -1,8 +1,15 @@
-import React, { PropsWithChildren, useContext, useState } from 'react';
+import React, { PropsWithChildren, useContext, useMemo, useState } from 'react';
-import { Channel } from 'stream-chat';
-
-import { EditChannelDetailsStore } from '../../state-store/edit-channel-details-store';
+import { NotificationTargetProvider } from '../../components/Notifications/NotificationTargetContext';
+import { useChannelActions } from '../../hooks/actions/useChannelActions';
+import { useStableCallback } from '../../hooks/useStableCallback';
+import {
+ EditChannelDetailsStore,
+ isImageEdited,
+ isNameEdited,
+} from '../../state-store/edit-channel-details-store';
+import type { File, GlobalFileUploadRequest } from '../../types/types';
+import { useChannelDetailsContext } from '../channelDetailsContext/channelDetailsContext';
import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue';
import { isTestEnvironment } from '../utils/isTestEnvironment';
@@ -11,30 +18,114 @@ import { isTestEnvironment } from '../utils/isTestEnvironment';
*/
export type ChannelEditDetailsContextValue = {
store: EditChannelDetailsStore;
+ /**
+ * Saves the edited channel details (name and/or image). Resolves once every
+ * update succeeds and rejects if any of them fail.
+ */
+ submit: () => Promise;
+ /**
+ * Compress image with quality (from 0 to 1, where 1 is best quality) applied
+ * to the channel image picked during editing.
+ */
+ compressImageQuality?: number;
+ /** Override the upload request used to upload the channel image. */
+ doFileUploadRequest?: GlobalFileUploadRequest;
};
+/**
+ * Props for the {@link ChannelEditDetailsProvider} that seed the edit flow
+ * configuration into the context.
+ */
+export type ChannelEditDetailsProviderProps = PropsWithChildren<{
+ /**
+ * Compress image with quality (from 0 to 1, where 1 is best quality) applied
+ * to the channel image picked during editing.
+ */
+ compressImageQuality?: number;
+ /** Override the upload request used to upload the channel image. */
+ doFileUploadRequest?: GlobalFileUploadRequest;
+}>;
+
export const ChannelEditDetailsContext = React.createContext(
DEFAULT_BASE_CONTEXT_VALUE as ChannelEditDetailsContextValue,
);
+/**
+ * Builds the {@link ChannelEditDetailsContextValue}. Rendered inside the
+ * {@link NotificationTargetProvider} so that notifications emitted by `submit`
+ * (via {@link useChannelActions}) resolve to the channel edit details host.
+ */
+const ChannelEditDetailsContextProviderInner = ({
+ children,
+ compressImageQuality,
+ doFileUploadRequest,
+}: ChannelEditDetailsProviderProps) => {
+ const { channel } = useChannelDetailsContext();
+ const { updateImage, updateName } = useChannelActions(channel);
+ const [store] = useState(() => new EditChannelDetailsStore(channel));
+
+ const submit = useStableCallback(async () => {
+ const state = store.state.getLatestValue();
+ const { currentName, updatedImage } = state;
+ const errors: unknown[] = [];
+ const onFailure = (error: unknown) => errors.push(error);
+ const tasks: Promise[] = [];
+ if (isNameEdited(state)) {
+ tasks.push(updateName(currentName, { onFailure }));
+ }
+ if (isImageEdited(state)) {
+ tasks.push(updateImage(updatedImage as File | null, { onFailure }, doFileUploadRequest));
+ }
+ await Promise.all(tasks);
+ if (errors.length > 0) {
+ throw new AggregateError(errors, 'Failed to update channel details');
+ }
+ });
+
+ const value = useMemo(
+ () => ({
+ compressImageQuality,
+ doFileUploadRequest,
+ store,
+ submit,
+ }),
+ [compressImageQuality, doFileUploadRequest, store, submit],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
/**
* Creates and provides an {@link EditChannelDetailsStore} snapshotted from the
- * given channel. Mount this once per edit session — the store captures the
+ * channel in the {@link ChannelDetailsContext}. Mount this once per edit session — the store captures the
* channel's name/image at construction and does not track later WebSocket
* updates, so an inbound `channel.updated` does not clobber in-progress edits.
*
* @experimental This API is experimental and is subject to change.
*/
export const ChannelEditDetailsProvider = ({
- channel,
children,
-}: PropsWithChildren<{ channel: Channel }>) => {
- const [store] = useState(() => new EditChannelDetailsStore(channel));
+ compressImageQuality,
+ doFileUploadRequest,
+}: ChannelEditDetailsProviderProps) => {
+ const { channel } = useChannelDetailsContext();
return (
-
- {children}
-
+
+
+ {children}
+
+
);
};
diff --git a/package/src/contexts/channelFileAttachmentListContext/ChannelFileAttachmentListContext.tsx b/package/src/contexts/channelFileAttachmentListContext/ChannelFileAttachmentListContext.tsx
index e46e28b804..96b3cccf31 100644
--- a/package/src/contexts/channelFileAttachmentListContext/ChannelFileAttachmentListContext.tsx
+++ b/package/src/contexts/channelFileAttachmentListContext/ChannelFileAttachmentListContext.tsx
@@ -10,7 +10,6 @@ import { isTestEnvironment } from '../utils/isTestEnvironment';
* @experimental This API is experimental and is subject to change.
*/
export type ChannelFileAttachmentListContextValue = {
- channel: Channel;
searchSource: MessageSearchSource;
};
@@ -24,9 +23,10 @@ export const ChannelFileAttachmentListContext = React.createContext(
export const ChannelFileAttachmentListProvider = ({
channel,
children,
-}: PropsWithChildren<{ channel: Channel }>) => {
+ searchSource: searchSourceProp,
+}: PropsWithChildren<{ channel: Channel; searchSource?: MessageSearchSource }>) => {
const { client } = useChatContext();
- const [searchSource] = useState(() => {
+ const [defaultSearchSource] = useState(() => {
const source = new MessageSearchSource(client, {
allowEmptySearchString: true,
pageSize: 25,
@@ -41,9 +41,10 @@ export const ChannelFileAttachmentListProvider = ({
source.activate();
return source;
});
+ const searchSource = searchSourceProp ?? defaultSearchSource;
return (
-
+
{children}
);
diff --git a/package/src/contexts/channelMediaListContext/ChannelMediaListContext.tsx b/package/src/contexts/channelMediaListContext/ChannelMediaListContext.tsx
index f8019f3d80..c6ffea3254 100644
--- a/package/src/contexts/channelMediaListContext/ChannelMediaListContext.tsx
+++ b/package/src/contexts/channelMediaListContext/ChannelMediaListContext.tsx
@@ -10,7 +10,6 @@ import { isTestEnvironment } from '../utils/isTestEnvironment';
* @experimental This API is experimental and is subject to change.
*/
export type ChannelMediaListContextValue = {
- channel: Channel;
searchSource: MessageSearchSource;
};
@@ -24,9 +23,10 @@ export const ChannelMediaListContext = React.createContext(
export const ChannelMediaListProvider = ({
channel,
children,
-}: PropsWithChildren<{ channel: Channel }>) => {
+ searchSource: searchSourceProp,
+}: PropsWithChildren<{ channel: Channel; searchSource?: MessageSearchSource }>) => {
const { client } = useChatContext();
- const [searchSource] = useState(() => {
+ const [defaultSearchSource] = useState(() => {
const source = new MessageSearchSource(client, {
allowEmptySearchString: true,
pageSize: 25,
@@ -42,9 +42,10 @@ export const ChannelMediaListProvider = ({
source.activate();
return source;
});
+ const searchSource = searchSourceProp ?? defaultSearchSource;
return (
-
+
{children}
);
diff --git a/package/src/contexts/channelMemberListContext/ChannelMemberListContext.tsx b/package/src/contexts/channelMemberListContext/ChannelMemberListContext.tsx
index c3a302e7d5..dac299b12b 100644
--- a/package/src/contexts/channelMemberListContext/ChannelMemberListContext.tsx
+++ b/package/src/contexts/channelMemberListContext/ChannelMemberListContext.tsx
@@ -9,7 +9,6 @@ import { isTestEnvironment } from '../utils/isTestEnvironment';
* @experimental This API is experimental and is subject to change.
*/
export type ChannelMemberListContextValue = {
- channel: Channel;
searchSource: ChannelMemberSearchSource;
};
@@ -23,8 +22,9 @@ export const ChannelMemberListContext = React.createContext(
export const ChannelMemberListProvider = ({
channel,
children,
-}: PropsWithChildren<{ channel: Channel }>) => {
- const [searchSource] = useState(() => {
+ searchSource: searchSourceProp,
+}: PropsWithChildren<{ channel: Channel; searchSource?: ChannelMemberSearchSource }>) => {
+ const [defaultSearchSource] = useState(() => {
const source = new ChannelMemberSearchSource(
channel,
{
@@ -46,9 +46,10 @@ export const ChannelMemberListProvider = ({
source.activate();
return source;
});
+ const searchSource = searchSourceProp ?? defaultSearchSource;
return (
-
+
{children}
);
diff --git a/package/src/contexts/channelPinnedMessageListContext/ChannelPinnedMessageListContext.tsx b/package/src/contexts/channelPinnedMessageListContext/ChannelPinnedMessageListContext.tsx
index 7eeb179bb2..3792e74d82 100644
--- a/package/src/contexts/channelPinnedMessageListContext/ChannelPinnedMessageListContext.tsx
+++ b/package/src/contexts/channelPinnedMessageListContext/ChannelPinnedMessageListContext.tsx
@@ -10,7 +10,6 @@ import { isTestEnvironment } from '../utils/isTestEnvironment';
* @experimental This API is experimental and is subject to change.
*/
export type ChannelPinnedMessageListContextValue = {
- channel: Channel;
searchSource: MessageSearchSource;
};
@@ -24,9 +23,10 @@ export const ChannelPinnedMessageListContext = React.createContext(
export const ChannelPinnedMessageListProvider = ({
channel,
children,
-}: PropsWithChildren<{ channel: Channel }>) => {
+ searchSource: searchSourceProp,
+}: PropsWithChildren<{ channel: Channel; searchSource?: MessageSearchSource }>) => {
const { client } = useChatContext();
- const [searchSource] = useState(() => {
+ const [defaultSearchSource] = useState(() => {
const source = new MessageSearchSource(
client,
{ pageSize: 25, allowEmptySearchString: true, resetOnNewSearchQuery: false },
@@ -46,9 +46,10 @@ export const ChannelPinnedMessageListProvider = ({
source.activate();
return source;
});
+ const searchSource = searchSourceProp ?? defaultSearchSource;
return (
-
+
{children}
);
diff --git a/package/src/contexts/componentsContext/defaultComponents.ts b/package/src/contexts/componentsContext/defaultComponents.ts
index d6cb674696..847500e58a 100644
--- a/package/src/contexts/componentsContext/defaultComponents.ts
+++ b/package/src/contexts/componentsContext/defaultComponents.ts
@@ -32,7 +32,10 @@ import { AutoCompleteSuggestionList } from '../../components/AutoCompleteInput/A
import { InputView } from '../../components/AutoCompleteInput/InputView';
import { ChannelDetailsContent } from '../../components/ChannelDetails/ChannelDetails';
import {
- ChannelAddMembers,
+ ChannelAddMembersButton,
+ ChannelAddMembersForm,
+ ChannelAddMembersFormContent,
+ ChannelAddMembersFormHeader,
ChannelDetailsActionsSection,
ChannelDetailsActionItem,
ChannelDetailsMemberSection,
@@ -40,7 +43,9 @@ import {
ChannelDetailsProfile,
ChannelDetailsEditButton,
ChannelDetailsNavHeader,
- ChannelEditDetails,
+ ChannelEditDetailsForm,
+ ChannelEditDetailsFormContent,
+ ChannelEditDetailsFormHeader,
ChannelEditImageSheet,
ChannelEditName,
ChannelMemberActionsSheet,
@@ -319,7 +324,10 @@ const components = {
ChannelDetailsHeader,
// Channel Details Screen
- ChannelAddMembers,
+ ChannelAddMembersButton,
+ ChannelAddMembersForm,
+ ChannelAddMembersFormContent,
+ ChannelAddMembersFormHeader,
ChannelDetailsActionsSection,
ChannelDetailsActionItem,
ChannelDetailsMemberSection,
@@ -328,7 +336,9 @@ const components = {
ChannelDetailsContent,
ChannelDetailsEditButton,
ChannelDetailsNavHeader,
- ChannelEditDetails,
+ ChannelEditDetailsForm,
+ ChannelEditDetailsFormContent,
+ ChannelEditDetailsFormHeader,
ChannelEditImageSheet,
ChannelEditName,
ChannelMemberActionsSheet,
diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts
index 7aed6fa7a0..11e3b246ff 100644
--- a/package/src/contexts/themeContext/utils/theme.ts
+++ b/package/src/contexts/themeContext/utils/theme.ts
@@ -207,6 +207,7 @@ export type Theme = {
trailingValue: TextStyle;
};
memberSection: {
+ addButtonWrapper: ViewStyle;
confirmButton: ViewStyle;
footer: ViewStyle;
header: ViewStyle;
@@ -1322,6 +1323,7 @@ export const defaultTheme: Theme = {
trailingValue: {},
},
memberSection: {
+ addButtonWrapper: {},
confirmButton: {},
footer: {},
header: {},
diff --git a/package/src/hooks/__tests__/useCanAddMembersToChannel.test.tsx b/package/src/hooks/__tests__/useCanAddMembersToChannel.test.tsx
new file mode 100644
index 0000000000..8baea8ac20
--- /dev/null
+++ b/package/src/hooks/__tests__/useCanAddMembersToChannel.test.tsx
@@ -0,0 +1,43 @@
+import { renderHook } from '@testing-library/react-native';
+import type { Channel } from 'stream-chat';
+
+import { useCanAddMembersToChannel } from '../useCanAddMembersToChannel';
+import { useChannelOwnCapabilities } from '../useChannelOwnCapabilities';
+
+jest.mock('../useChannelOwnCapabilities');
+
+const mockedUseChannelOwnCapabilities = useChannelOwnCapabilities as jest.MockedFunction<
+ typeof useChannelOwnCapabilities
+>;
+
+const channel = {} as Channel;
+
+describe('useCanAddMembersToChannel', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns true when the user can update channel members', () => {
+ mockedUseChannelOwnCapabilities.mockReturnValue(['update-channel-members']);
+
+ const { result } = renderHook(() => useCanAddMembersToChannel(channel));
+
+ expect(result.current).toBe(true);
+ });
+
+ it('returns false when the update-channel-members capability is missing', () => {
+ mockedUseChannelOwnCapabilities.mockReturnValue([]);
+
+ const { result } = renderHook(() => useCanAddMembersToChannel(channel));
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns false when there is no channel', () => {
+ mockedUseChannelOwnCapabilities.mockReturnValue(undefined);
+
+ const { result } = renderHook(() => useCanAddMembersToChannel());
+
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/package/src/hooks/__tests__/useCanEditChannel.test.tsx b/package/src/hooks/__tests__/useCanEditChannel.test.tsx
new file mode 100644
index 0000000000..8088d82fbc
--- /dev/null
+++ b/package/src/hooks/__tests__/useCanEditChannel.test.tsx
@@ -0,0 +1,43 @@
+import { renderHook } from '@testing-library/react-native';
+import type { Channel } from 'stream-chat';
+
+import { useCanEditChannel } from '../useCanEditChannel';
+import { useChannelOwnCapabilities } from '../useChannelOwnCapabilities';
+
+jest.mock('../useChannelOwnCapabilities');
+
+const mockedUseChannelOwnCapabilities = useChannelOwnCapabilities as jest.MockedFunction<
+ typeof useChannelOwnCapabilities
+>;
+
+const channel = {} as Channel;
+
+describe('useCanEditChannel', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns true when the user can update the channel', () => {
+ mockedUseChannelOwnCapabilities.mockReturnValue(['update-channel']);
+
+ const { result } = renderHook(() => useCanEditChannel(channel));
+
+ expect(result.current).toBe(true);
+ });
+
+ it('returns false when the update-channel capability is missing', () => {
+ mockedUseChannelOwnCapabilities.mockReturnValue([]);
+
+ const { result } = renderHook(() => useCanEditChannel(channel));
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns false when there is no channel', () => {
+ mockedUseChannelOwnCapabilities.mockReturnValue(undefined);
+
+ const { result } = renderHook(() => useCanEditChannel());
+
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts
index 37708d4e18..cc22ef35ad 100644
--- a/package/src/hooks/index.ts
+++ b/package/src/hooks/index.ts
@@ -6,6 +6,8 @@ export * from './useChannelName';
export * from './useChannelImage';
export * from './useChannelMembershipState';
export * from './useChannelOwnCapabilities';
+export * from './useCanEditChannel';
+export * from './useCanAddMembersToChannel';
export * from './useChannelMuteActive';
export * from './useIsDirectChat';
export * from './useIsChannelMember';
diff --git a/package/src/hooks/useCanAddMembersToChannel.ts b/package/src/hooks/useCanAddMembersToChannel.ts
new file mode 100644
index 0000000000..4a4f315e62
--- /dev/null
+++ b/package/src/hooks/useCanAddMembersToChannel.ts
@@ -0,0 +1,9 @@
+import type { Channel } from 'stream-chat';
+
+import { useChannelOwnCapabilities } from './useChannelOwnCapabilities';
+
+/**
+ * Whether the current user can add members to the channel.
+ */
+export const useCanAddMembersToChannel = (channel?: Channel) =>
+ useChannelOwnCapabilities(channel)?.includes('update-channel-members') ?? false;
diff --git a/package/src/hooks/useCanEditChannel.ts b/package/src/hooks/useCanEditChannel.ts
new file mode 100644
index 0000000000..07da9b5c0d
--- /dev/null
+++ b/package/src/hooks/useCanEditChannel.ts
@@ -0,0 +1,9 @@
+import type { Channel } from 'stream-chat';
+
+import { useChannelOwnCapabilities } from './useChannelOwnCapabilities';
+
+/**
+ * Whether the current user can update (edit) the channel.
+ */
+export const useCanEditChannel = (channel?: Channel) =>
+ useChannelOwnCapabilities(channel)?.includes('update-channel') ?? false;
diff --git a/package/src/hooks/useIsDirectChat.ts b/package/src/hooks/useIsDirectChat.ts
index bb890dc248..e781cac073 100644
--- a/package/src/hooks/useIsDirectChat.ts
+++ b/package/src/hooks/useIsDirectChat.ts
@@ -5,13 +5,13 @@ import type { Channel } from 'stream-chat';
import { useChannelMembersState } from '../components/ChannelList/hooks/useChannelMembersState';
import { useChatContext } from '../contexts';
-export const useIsDirectChat = (channel: Channel) => {
+export const useIsDirectChat = (channel?: Channel) => {
const { client } = useChatContext();
const ownUserId = client.userID;
const members = useChannelMembersState(channel);
const otherMembers = useMemo(
- () => Object.values(members).filter((member) => member.user?.id !== ownUserId),
+ () => Object.values(members ?? {}).filter((member) => member.user?.id !== ownUserId),
[members, ownUserId],
);
diff --git a/package/src/state-store/__tests__/edit-channel-details-store.test.ts b/package/src/state-store/__tests__/edit-channel-details-store.test.ts
index 6d2c9f56f5..1ef3e7c96d 100644
--- a/package/src/state-store/__tests__/edit-channel-details-store.test.ts
+++ b/package/src/state-store/__tests__/edit-channel-details-store.test.ts
@@ -7,8 +7,9 @@ import { getTestClientWithUser } from '../../mock-builders/mock';
import type { File } from '../../types/types';
import {
EditChannelDetailsStore,
- useIsImageDirty,
- useIsNameDirty,
+ useAreChannelDetailsEdited,
+ useIsImageEdited,
+ useIsNameEdited,
} from '../edit-channel-details-store';
const file: File = {
@@ -98,12 +99,12 @@ describe('EditChannelDetailsStore', () => {
});
});
- describe('useIsNameDirty', () => {
+ describe('useIsNameEdited', () => {
it('is false initially', async () => {
const channel = await createChannel({ name: 'Original' });
const store = new EditChannelDetailsStore(channel);
- const { result } = renderHook(() => useIsNameDirty(store));
+ const { result } = renderHook(() => useIsNameEdited(store));
expect(result.current).toBe(false);
});
@@ -111,7 +112,7 @@ describe('EditChannelDetailsStore', () => {
const channel = await createChannel({ name: 'Original' });
const store = new EditChannelDetailsStore(channel);
- const { result } = renderHook(() => useIsNameDirty(store));
+ const { result } = renderHook(() => useIsNameEdited(store));
expect(result.current).toBe(false);
act(() => store.setCurrentName('Renamed'));
@@ -121,11 +122,11 @@ describe('EditChannelDetailsStore', () => {
expect(result.current).toBe(false);
});
- it('treats untrimmed whitespace as dirty', async () => {
+ it('treats untrimmed whitespace as edited', async () => {
const channel = await createChannel({ name: 'Original' });
const store = new EditChannelDetailsStore(channel);
- const { result } = renderHook(() => useIsNameDirty(store));
+ const { result } = renderHook(() => useIsNameEdited(store));
act(() => store.setCurrentName('Original '));
expect(result.current).toBe(true);
@@ -135,27 +136,27 @@ describe('EditChannelDetailsStore', () => {
const channel = await createChannel({ name: 'Original' });
const store = new EditChannelDetailsStore(channel);
- const { result } = renderHook(() => useIsNameDirty(store));
+ const { result } = renderHook(() => useIsNameEdited(store));
act(() => store.setUpdatedImage(file));
expect(result.current).toBe(false);
});
});
- describe('useIsImageDirty', () => {
+ describe('useIsImageEdited', () => {
it('is false initially', async () => {
const channel = await createChannel({ image: 'http://img/original.png' });
const store = new EditChannelDetailsStore(channel);
- const { result } = renderHook(() => useIsImageDirty(store));
+ const { result } = renderHook(() => useIsImageEdited(store));
expect(result.current).toBe(false);
});
- it('is dirty after a new image is picked', async () => {
+ it('is edited after a new image is picked', async () => {
const channel = await createChannel({ name: 'Original' });
const store = new EditChannelDetailsStore(channel);
- const { result } = renderHook(() => useIsImageDirty(store));
+ const { result } = renderHook(() => useIsImageEdited(store));
act(() => store.setUpdatedImage(file));
expect(result.current).toBe(true);
@@ -165,18 +166,18 @@ describe('EditChannelDetailsStore', () => {
const channel = await createChannel({ name: 'Original' });
const store = new EditChannelDetailsStore(channel);
- const { result } = renderHook(() => useIsImageDirty(store));
+ const { result } = renderHook(() => useIsImageEdited(store));
act(() => store.setCurrentName('Renamed'));
expect(result.current).toBe(false);
});
describe('with an initial image', () => {
- it('is dirty on reset and clean again when untouched', async () => {
+ it('is edited on reset and clean again when untouched', async () => {
const channel = await createChannel({ image: 'http://img/original.png' });
const store = new EditChannelDetailsStore(channel);
- const { result } = renderHook(() => useIsImageDirty(store));
+ const { result } = renderHook(() => useIsImageEdited(store));
expect(result.current).toBe(false);
act(() => store.setUpdatedImage(null));
@@ -188,11 +189,11 @@ describe('EditChannelDetailsStore', () => {
});
describe('without an initial image', () => {
- it('is not dirty on reset but is dirty when a file is picked', async () => {
+ it('is not edited on reset but is edited when a file is picked', async () => {
const channel = await createChannel();
const store = new EditChannelDetailsStore(channel);
- const { result } = renderHook(() => useIsImageDirty(store));
+ const { result } = renderHook(() => useIsImageEdited(store));
act(() => store.setUpdatedImage(null));
expect(result.current).toBe(false);
@@ -202,4 +203,58 @@ describe('EditChannelDetailsStore', () => {
});
});
});
+
+ describe('useAreChannelDetailsEdited', () => {
+ it('is false initially', async () => {
+ const channel = await createChannel({ image: 'http://img/original.png', name: 'Original' });
+ const store = new EditChannelDetailsStore(channel);
+
+ const { result } = renderHook(() => useAreChannelDetailsEdited(store));
+ expect(result.current).toBe(false);
+ });
+
+ it('is edited when the name changes and clean again when reverted', async () => {
+ const channel = await createChannel({ name: 'Original' });
+ const store = new EditChannelDetailsStore(channel);
+
+ const { result } = renderHook(() => useAreChannelDetailsEdited(store));
+
+ act(() => store.setCurrentName('Renamed'));
+ expect(result.current).toBe(true);
+
+ act(() => store.setCurrentName('Original'));
+ expect(result.current).toBe(false);
+ });
+
+ it('is edited when a new image is picked', async () => {
+ const channel = await createChannel({ name: 'Original' });
+ const store = new EditChannelDetailsStore(channel);
+
+ const { result } = renderHook(() => useAreChannelDetailsEdited(store));
+
+ act(() => store.setUpdatedImage(file));
+ expect(result.current).toBe(true);
+ });
+
+ it('stays edited while either field is edited', async () => {
+ const channel = await createChannel({ name: 'Original' });
+ const store = new EditChannelDetailsStore(channel);
+
+ const { result } = renderHook(() => useAreChannelDetailsEdited(store));
+
+ act(() => {
+ store.setCurrentName('Renamed');
+ store.setUpdatedImage(file);
+ });
+ expect(result.current).toBe(true);
+
+ // Revert the name; image is still edited, so the form stays edited.
+ act(() => store.setCurrentName('Original'));
+ expect(result.current).toBe(true);
+
+ // Revert the image too; now the form is clean.
+ act(() => store.setUpdatedImage(undefined));
+ expect(result.current).toBe(false);
+ });
+ });
});
diff --git a/package/src/state-store/__tests__/signal-store.test.ts b/package/src/state-store/__tests__/signal-store.test.ts
new file mode 100644
index 0000000000..38348cfadb
--- /dev/null
+++ b/package/src/state-store/__tests__/signal-store.test.ts
@@ -0,0 +1,35 @@
+import { SignalStore } from '../signal-store';
+
+describe('SignalStore', () => {
+ it('starts from the expected initial state', () => {
+ const store = new SignalStore();
+
+ expect(store.state.getLatestValue().signal).toBe(false);
+ });
+
+ it('settles back to false after signalling', () => {
+ const store = new SignalStore();
+
+ store.signal();
+
+ expect(store.state.getLatestValue().signal).toBe(false);
+ });
+
+ it('emits a true then false pulse to subscribers', () => {
+ const store = new SignalStore();
+ const observed: boolean[] = [];
+
+ const unsubscribe = store.state.subscribeWithSelector(
+ (state) => ({ signal: state.signal }),
+ ({ signal }) => observed.push(signal),
+ );
+
+ store.signal();
+ unsubscribe();
+
+ // subscribeWithSelector emits the initial value (false) immediately, then the
+ // signal() pulse drives a true followed by a false. The `if (signal)` guard in
+ // ChannelDetailsModal only reacts to the true edge.
+ expect(observed).toEqual([false, true, false]);
+ });
+});
diff --git a/package/src/state-store/edit-channel-details-store.ts b/package/src/state-store/edit-channel-details-store.ts
index 777cb27324..55a8927f87 100644
--- a/package/src/state-store/edit-channel-details-store.ts
+++ b/package/src/state-store/edit-channel-details-store.ts
@@ -68,23 +68,27 @@ export class EditChannelDetailsStore {
}
/** Whether the name input differs from the channel's initial name. */
-export const isNameDirty = (state: EditChannelDetailsState) =>
+export const isNameEdited = (state: EditChannelDetailsState) =>
state.currentName !== state.initialName;
/**
- * Whether the image has unsaved changes. The image is dirty once touched
+ * Whether the image has unsaved changes. The image is edited once touched
* (`updatedImage !== undefined`), except when both the initial and updated
* image are falsy (no image before, none now).
*/
-export const isImageDirty = (state: EditChannelDetailsState) =>
+export const isImageEdited = (state: EditChannelDetailsState) =>
!(state.updatedImage === undefined || (!state.updatedImage && !state.initialImage));
-const selectIsNameDirty = (state: EditChannelDetailsState) => ({
- isNameDirty: isNameDirty(state),
+const selectIsNameEdited = (state: EditChannelDetailsState) => ({
+ isNameEdited: isNameEdited(state),
});
-const selectIsImageDirty = (state: EditChannelDetailsState) => ({
- isImageDirty: isImageDirty(state),
+const selectIsImageEdited = (state: EditChannelDetailsState) => ({
+ isImageEdited: isImageEdited(state),
+});
+
+const selectAreChannelDetailsEdited = (state: EditChannelDetailsState) => ({
+ areChannelDetailsEdited: isNameEdited(state) || isImageEdited(state),
});
/**
@@ -93,8 +97,8 @@ const selectIsImageDirty = (state: EditChannelDetailsState) => ({
*
* @experimental This API is experimental and is subject to change.
*/
-export const useIsNameDirty = (store: EditChannelDetailsStore) =>
- useStateStore(store.state, selectIsNameDirty).isNameDirty;
+export const useIsNameEdited = (store: EditChannelDetailsStore) =>
+ useStateStore(store.state, selectIsNameEdited).isNameEdited;
/**
* Subscribes to an {@link EditChannelDetailsStore} and returns whether the image
@@ -102,5 +106,14 @@ export const useIsNameDirty = (store: EditChannelDetailsStore) =>
*
* @experimental This API is experimental and is subject to change.
*/
-export const useIsImageDirty = (store: EditChannelDetailsStore) =>
- useStateStore(store.state, selectIsImageDirty).isImageDirty;
+export const useIsImageEdited = (store: EditChannelDetailsStore) =>
+ useStateStore(store.state, selectIsImageEdited).isImageEdited;
+
+/**
+ * Subscribes to an {@link EditChannelDetailsStore} and returns whether the form
+ * has any unsaved changes (name or image).
+ *
+ * @experimental This API is experimental and is subject to change.
+ */
+export const useAreChannelDetailsEdited = (store: EditChannelDetailsStore) =>
+ useStateStore(store.state, selectAreChannelDetailsEdited).areChannelDetailsEdited;
diff --git a/package/src/state-store/index.ts b/package/src/state-store/index.ts
index 015248e1b0..61b8609e2a 100644
--- a/package/src/state-store/index.ts
+++ b/package/src/state-store/index.ts
@@ -1,7 +1,10 @@
export * from './in-app-notifications-store';
+export * from './edit-channel-details-store';
+export * from './selection-store';
export * from './audio-player';
export * from './audio-player-pool';
export * from './video-player';
export * from './video-player-pool';
export * from './image-gallery-state-store';
export * from './message-overlay-store';
+export * from './signal-store';
diff --git a/package/src/state-store/signal-store.ts b/package/src/state-store/signal-store.ts
new file mode 100644
index 0000000000..7b4552efe3
--- /dev/null
+++ b/package/src/state-store/signal-store.ts
@@ -0,0 +1,18 @@
+import { StateStore } from 'stream-chat';
+
+export type SignalState = {
+ signal: boolean;
+};
+
+const INITIAL_STATE: SignalState = {
+ signal: false,
+};
+
+export class SignalStore {
+ public state = new StateStore(INITIAL_STATE);
+
+ signal() {
+ this.state.partialNext({ signal: true });
+ this.state.partialNext({ signal: false });
+ }
+}