diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index a51a9a6a06..af102b0912 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -64,7 +64,7 @@ "react-native-teleport": "^1.1.7", "react-native-video": "^6.19.2", "react-native-worklets": "^0.8.3", - "stream-chat": "^9.45.6", + "stream-chat": "^9.46.0", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/src/components/ChannelDetailsNavigationSection.tsx b/examples/SampleApp/src/components/ChannelDetailsNavigationSection.tsx deleted file mode 100644 index 73b0c30354..0000000000 --- a/examples/SampleApp/src/components/ChannelDetailsNavigationSection.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, { useMemo } from 'react'; -import { StyleSheet, View } from 'react-native'; - -import { useNavigation } from '@react-navigation/native'; -import type { NavigationProp } from '@react-navigation/native'; - -import { - ChannelDetailsActionItem, - ChevronRight, - FilePickerIcon, - ImageGrid, - Pin, - useChannelDetailsContext, - useTheme, - useTranslationContext, -} from 'stream-chat-react-native'; - -import type { StackNavigatorParamList } from '../types'; - -/** - * SampleApp implementation of the (now default-less) `ChannelDetailsNavigationSection` - * slot. It wires the Pinned Messages / Photos & Videos / Files rows to the app's own - * navigation screens. Registered via `useSampleAppComponentOverrides`. - */ -export const ChannelDetailsNavigationSection = () => { - const { t } = useTranslationContext(); - const { channel } = useChannelDetailsContext(); - const navigation = useNavigation>(); - const { - theme: { - channelDetails: { sectionCard: sectionCardOverride }, - semantics, - }, - } = useTheme(); - const styles = useStyles(); - - const chevron = useMemo( - () => ( - - - - ), - [semantics.textTertiary], - ); - - return ( - - navigation.navigate('ChannelPinnedMessagesScreen', { channel })} - testID='channel-details-pinned-messages' - trailing={chevron} - /> - navigation.navigate('ChannelImagesScreen', { channel })} - testID='channel-details-photos-and-videos' - trailing={chevron} - /> - navigation.navigate('ChannelFilesScreen', { channel })} - testID='channel-details-files' - trailing={chevron} - /> - - ); -}; - -const useStyles = () => - useMemo( - () => - StyleSheet.create({ - sectionCard: { - borderRadius: 16, - overflow: 'hidden', - paddingVertical: 4, - }, - }), - [], - ); diff --git a/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx b/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx index c788e34704..8154842690 100644 --- a/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx +++ b/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx @@ -6,7 +6,6 @@ import type { ComponentOverrides } from 'stream-chat-react-native'; import { useTheme } from 'stream-chat-react-native'; import { CustomAttachmentPickerContent } from './AttachmentPickerContent'; -import { ChannelDetailsNavigationSection } from './ChannelDetailsNavigationSection'; import { FastImageAdapter } from './FastImageAdapter'; import { MessageLocation } from './LocationSharing/MessageLocation'; import type { MessageOverlayBackdropConfigItem } from './SecretMenu'; @@ -45,7 +44,6 @@ export const useSampleAppComponentOverrides = ( useMemo( () => ({ AttachmentPickerContent: CustomAttachmentPickerContent, - ChannelDetailsNavigationSection, ChannelListHeaderNetworkDownIndicator: RenderNull, ImageComponent: FastImageAdapter, MessageLocation, diff --git a/examples/SampleApp/src/hooks/usePaginatedAttachments.ts b/examples/SampleApp/src/hooks/usePaginatedAttachments.ts deleted file mode 100644 index 0ed1769df0..0000000000 --- a/examples/SampleApp/src/hooks/usePaginatedAttachments.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -import type { Channel, MessageResponse } from 'stream-chat'; - -import { useAppContext } from '../context/AppContext'; - -export const usePaginatedAttachments = (channel: Channel, attachmentType: string) => { - const { chatClient } = useAppContext(); - const offset = useRef(0); - const hasMoreResults = useRef(true); - const queryInProgress = useRef(false); - const [loading, setLoading] = useState(true); - const [messages, setMessages] = useState([]); - - const fetchAttachments = async () => { - if (queryInProgress.current) { - return; - } - - setLoading(true); - - try { - queryInProgress.current = true; - - offset.current = offset.current + messages.length; - - if (!hasMoreResults.current) { - queryInProgress.current = false; - setLoading(false); - return; - } - - // TODO: Use this when support for attachment_type is ready. - const res = await chatClient?.search( - { - cid: { $in: [channel.cid] }, - }, - { 'attachments.type': { $in: [attachmentType] } }, - { - limit: 10, - offset: offset.current, - }, - ); - - const newMessages = res?.results.map((r) => r.message); - - if (!newMessages) { - queryInProgress.current = false; - setLoading(false); - return; - } - - setMessages((existingMessages) => existingMessages.concat(newMessages)); - - if (newMessages.length < 10) { - hasMoreResults.current = false; - } - } catch (e) { - console.warn('An error has occurred while fetching attachments: ', e); - // do nothing; - } - queryInProgress.current = false; - setLoading(false); - }; - - const loadMore = () => { - fetchAttachments(); - }; - - useEffect(() => { - fetchAttachments(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return { - loading, - loadMore, - messages, - }; -}; diff --git a/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx index cfd6b49dc5..013dfaf9bb 100644 --- a/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx @@ -5,10 +5,12 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { ChannelDetails, + GetChannelDetailsNavigationItems, GetChannelMemberActionItems, ChannelAddMembersModal, ChannelAllMembersModal, ChannelDetailsContextProvider, + ChannelDetailsNavigationSectionType, } from 'stream-chat-react-native'; import { SendDirectMessage } from '../icons/SendDirectMessage'; @@ -27,6 +29,17 @@ type Props = { route: ChannelDetailsScreenRouteProp; }; +const navigationItems: { + [key in ChannelDetailsNavigationSectionType]: + | 'ChannelPinnedMessagesScreen' + | 'ChannelImagesScreen' + | 'ChannelFilesScreen'; +} = { + 'pinned-messages': 'ChannelPinnedMessagesScreen', + 'photos-and-videos': 'ChannelImagesScreen', + files: 'ChannelFilesScreen', +}; + export const ChannelDetailsScreen: React.FC = ({ navigation, route: { @@ -34,6 +47,16 @@ export const ChannelDetailsScreen: React.FC = ({ }, }) => { const onBack = useCallback(() => navigation.goBack(), [navigation]); + const getNavigationItems = useCallback( + ({ defaultItems }) => + defaultItems.map((item) => ({ + ...item, + ...(navigationItems[item.section] + ? { onPress: () => navigation.navigate(navigationItems[item.section], { channel }) } + : {}), + })), + [navigation, channel], + ); const popToRoot = useCallback( () => navigation.reset({ @@ -82,6 +105,7 @@ export const ChannelDetailsScreen: React.FC = ({ ; @@ -81,138 +29,14 @@ export const ChannelFilesScreen: React.FC = ({ params: { channel }, }, }) => { - const { loading, loadMore, messages } = usePaginatedAttachments(channel, 'file'); - const insets = useSafeAreaInsets(); - const { - theme: { semantics }, - } = useTheme(); - const { black, grey, white_snow } = useLegacyColors(); - - const [sections, setSections] = useState< - Array<{ - data: Attachment[]; - title: string; - }> - >([]); - - useEffect(() => { - const newSections: Record< - string, - { - data: Attachment[]; - title: string; - } - > = {}; - - messages.forEach((message) => { - const month = Dayjs(message.created_at).format('MMM YYYY'); - - if (!newSections[month]) { - newSections[month] = { - data: [], - title: month, - }; - } - - message.attachments?.forEach((a) => { - if (a.type !== 'file') { - return; - } - - newSections[month].data.push(a); - }); - }); - - setSections(Object.values(newSections)); - }, [messages]); + useTheme(); return ( - + - - {(sections.length > 0 || !loading) && ( - - contentContainerStyle={styles.sectionContentContainer} - ListEmptyComponent={EmptyListComponent} - onEndReached={loadMore} - renderItem={({ index, item: attachment, section }) => ( - { - Alert.alert('Not implemented.'); - }} - style={{ - borderBottomColor: semantics.borderCoreDefault, - borderBottomWidth: index === section.data.length - 1 ? 0 : 1, - }} - > - - - - - {attachment.title} - - - {getFileSizeDisplayText(attachment.file_size)} - - - - - )} - renderSectionHeader={({ section: { title } }) => ( - - {title} - - )} - sections={sections} - stickySectionHeadersEnabled - /> - )} - - - ); -}; - -const EmptyListComponent = () => { - useTheme(); - const { black, grey, grey_gainsboro } = useLegacyColors(); - return ( - - - No files - - Files sent on this chat will appear here. - + + + ); }; diff --git a/examples/SampleApp/src/screens/ChannelImagesScreen.tsx b/examples/SampleApp/src/screens/ChannelImagesScreen.tsx index 1bdc76aa1e..372aa523bf 100644 --- a/examples/SampleApp/src/screens/ChannelImagesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelImagesScreen.tsx @@ -1,60 +1,15 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { - Dimensions, - FlatList, - Image, - StyleSheet, - Text, - TouchableOpacity, - View, - ViewToken, -} from 'react-native'; - -import { SafeAreaView } from 'react-native-safe-area-context'; +import React from 'react'; +import { StyleSheet, View } from 'react-native'; import type { RouteProp } from '@react-navigation/native'; -import Dayjs from 'dayjs'; -import { - DateHeader, - useImageGalleryContext, - useOverlayContext, - useTheme, - ImageGalleryState, - useStateStore, -} from 'stream-chat-react-native'; +import { ChannelDetailsContextProvider, MediaList } from 'stream-chat-react-native'; import { ScreenHeader } from '../components/ScreenHeader'; -import { usePaginatedAttachments } from '../hooks/usePaginatedAttachments'; -import { Picture } from '../icons/Picture'; -import { useLegacyColors } from '../theme/useLegacyColors'; import type { StackNavigatorParamList } from '../types'; -const screen = Dimensions.get('screen').width; - const styles = StyleSheet.create({ - contentContainer: { flexGrow: 1 }, - emptyContainer: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - paddingHorizontal: 40, - }, flex: { flex: 1 }, - noMedia: { - fontSize: 16, - paddingBottom: 8, - }, - noMediaDetails: { - fontSize: 14, - textAlign: 'center', - }, - stickyHeader: { - left: 0, - position: 'absolute', - right: 0, - top: 8, // DateHeader already has marginTop 8 - }, }); type ChannelImagesScreenRouteProp = RouteProp; @@ -63,126 +18,17 @@ export type ChannelImagesScreenProps = { route: ChannelImagesScreenRouteProp; }; -const selector = (state: ImageGalleryState) => ({ - assets: state.assets, -}); - export const ChannelImagesScreen: React.FC = ({ route: { params: { channel }, }, }) => { - const { imageGalleryStateStore } = useImageGalleryContext(); - const { assets } = useStateStore(imageGalleryStateStore.state, selector); - const { setOverlay } = useOverlayContext(); - const { loading, loadMore, messages } = usePaginatedAttachments(channel, 'image'); - useTheme(); - const { white } = useLegacyColors(); - - const [stickyHeaderDate, setStickyHeaderDate] = useState( - Dayjs(messages?.[0]?.created_at).format('MMM YYYY'), - ); - const stickyHeaderDateRef = useRef(''); - - const updateStickyDate = useRef(({ viewableItems }: { viewableItems: ViewToken[] }) => { - if (viewableItems?.length) { - const lastItem = viewableItems[0]; - - const created_at = lastItem?.item?.created_at; - - if ( - created_at && - !lastItem.item.deleted_at && - Dayjs(created_at).format('MMM YYYY') !== stickyHeaderDateRef.current - ) { - stickyHeaderDateRef.current = Dayjs(created_at).format('MMM YYYY'); - const isCurrentYear = new Date(created_at).getFullYear() === new Date().getFullYear(); - setStickyHeaderDate( - isCurrentYear ? Dayjs(created_at).format('MMM') : Dayjs(created_at).format('MMM YYYY'), - ); - } - } - }); - - const messagesWithImages = messages - .map((message) => ({ ...message, groupStyles: [], readBy: false })) - .filter((message) => { - if (!message.deleted_at && message.attachments) { - return message.attachments.some( - (attachment) => - attachment.type === 'image' && - !attachment.title_link && - !attachment.og_scrape_url && - (attachment.image_url || attachment.thumb_url), - ); - } - return false; - }); - - useEffect(() => { - imageGalleryStateStore.openImageGallery({ messages: messagesWithImages }); - return () => imageGalleryStateStore.clear(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [imageGalleryStateStore, messagesWithImages.length]); - - return ( - - - - `${item.id}-${index}`} - ListEmptyComponent={EmptyListComponent} - numColumns={3} - onEndReached={loadMore} - onViewableItemsChanged={updateStickyDate.current} - refreshing={loading} - renderItem={({ item }) => ( - { - imageGalleryStateStore.openImageGallery({ - messages: messagesWithImages, - selectedAttachmentUrl: item.uri, - }); - setOverlay('gallery'); - }} - > - - - )} - style={styles.flex} - viewabilityConfig={{ - viewAreaCoveragePercentThreshold: 50, - }} - /> - {assets.length > 0 ? ( - - - - ) : null} - - - ); -}; - -const EmptyListComponent = () => { - useTheme(); - const { black, grey, grey_gainsboro } = useLegacyColors(); return ( - - - No media - - Photos or video sent in this chat will appear here - + + + + + ); }; diff --git a/examples/SampleApp/src/screens/ChannelPinnedMessagesScreen.tsx b/examples/SampleApp/src/screens/ChannelPinnedMessagesScreen.tsx index 878429956d..f34846c966 100644 --- a/examples/SampleApp/src/screens/ChannelPinnedMessagesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelPinnedMessagesScreen.tsx @@ -1,16 +1,18 @@ -import React from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import React, { useCallback } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useNavigation, type RouteProp } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { + useTheme, + PinnedMessageList, + ChannelDetailsContextProvider, + PinnedMessageItemProps, + PinnedMessageItem, + WithComponents, +} from 'stream-chat-react-native'; -import type { RouteProp } from '@react-navigation/native'; -import { useTheme } from 'stream-chat-react-native'; - -import { MessageSearchList } from '../components/MessageSearch/MessageSearchList'; import { ScreenHeader } from '../components/ScreenHeader'; -import { usePaginatedPinnedMessages } from '../hooks/usePaginatedPinnedMessages'; -import { Message } from '../icons/Message'; -import { useLegacyColors } from '../theme/useLegacyColors'; import type { StackNavigatorParamList } from '../types'; @@ -69,50 +71,46 @@ type ChannelPinnedMessagesScreenRouteProp = RouteProp< 'ChannelPinnedMessagesScreen' >; +type ChannelPinnedMessagesScreenNavigationProp = NativeStackNavigationProp< + StackNavigatorParamList, + 'ChannelPinnedMessagesScreen' +>; + export type ChannelPinnedMessagesScreenProps = { route: ChannelPinnedMessagesScreenRouteProp; }; +const PinnedMessage = (props: PinnedMessageItemProps) => { + const navigation = useNavigation(); + + const onPress = useCallback(() => { + navigation.navigate('ChannelScreen', { + channel: props.channel, + messageId: props.message.parent_id ?? props.message.id, + }); + }, [props.channel, navigation, props.message.parent_id, props.message.id]); + + return ( + + + + ); +}; + export const ChannelPinnedMessagesScreen: React.FC = ({ route: { params: { channel }, }, }) => { useTheme(); - const { white_snow } = useLegacyColors(); - const { loading, loadMore, messages } = usePaginatedPinnedMessages(channel); - const insets = useSafeAreaInsets(); return ( - + - - - ); -}; - -const EmptyListComponent = () => { - useTheme(); - const { black, grey, grey_gainsboro } = useLegacyColors(); - return ( - - - No pinned messages - - Long-press an important message and choose Pin to conversation. - + + + + + ); }; diff --git a/package/package.json b/package/package.json index 0d17422096..8ddfc1e6d4 100644 --- a/package/package.json +++ b/package/package.json @@ -78,7 +78,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.45.6", + "stream-chat": "^9.46.0", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/package/src/components/ChannelDetails/ChannelDetails.tsx b/package/src/components/ChannelDetails/ChannelDetails.tsx index 2c5ab6bc3f..c14c3f4f51 100644 --- a/package/src/components/ChannelDetails/ChannelDetails.tsx +++ b/package/src/components/ChannelDetails/ChannelDetails.tsx @@ -3,6 +3,8 @@ import { ScrollView, StyleSheet, View } from 'react-native'; import type { Channel, ChannelMemberResponse } from 'stream-chat'; +import type { GetChannelDetailsNavigationItems } from './hooks/useChannelDetailsNavigationItems'; + import { ChannelDetailsContextProvider, type ChannelDetailsContextValue, @@ -59,6 +61,15 @@ export type ChannelDetailsProps = { * 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. * @@ -112,6 +123,7 @@ export const ChannelDetailsContent = () => { } = useTheme(); const { ChannelDetailsActionsSection, + ChannelDetailsEditButton, ChannelDetailsMemberSection, ChannelDetailsNavigationSection, ChannelDetailsProfile, @@ -128,10 +140,10 @@ export const ChannelDetailsContent = () => { containerOverride, ]} > - + } /> - {ChannelDetailsNavigationSection ? : null} + {isDirect ? null : } @@ -149,6 +161,7 @@ export const ChannelDetails = ({ getChannelActionItems, getChannelMemberActionItems, getMemberRoleLabel, + getNavigationItems, onAddMembersPress, onBack, onChannelDismiss, @@ -165,6 +178,7 @@ export const ChannelDetails = ({ getChannelActionItems, getChannelMemberActionItems, getMemberRoleLabel, + getNavigationItems, onAddMembersPress, onBack, onChannelDismiss, @@ -179,6 +193,7 @@ export const ChannelDetails = ({ getChannelActionItems, getChannelMemberActionItems, getMemberRoleLabel, + getNavigationItems, onAddMembersPress, onBack, onChannelDismiss, diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetails.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetails.test.tsx index bb3d46b5ed..903b88a9ef 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetails.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetails.test.tsx @@ -52,6 +52,15 @@ const channel = { on: jest.fn(() => ({ unsubscribe: jest.fn() })), } as unknown as Channel; +const buildChannel = (capabilities: string[] = []) => + ({ + cid: 'messaging:test', + data: { own_capabilities: capabilities }, + id: 'test', + on: jest.fn(() => ({ unsubscribe: jest.fn() })), + state: { members: {} }, + }) as unknown as Channel; + const renderContent = () => render( @@ -94,6 +103,26 @@ describe('ChannelDetailsContent', () => { renderContent(); expect(screen.queryByTestId('probe-member')).toBeNull(); }); + + it('displays the edit button in the header for a group channel with the update-channel capability', () => { + useIsDirectChatSpy.mockReturnValue(false); + render( + + + + + , + ); + + expect(screen.getByTestId('channel-details-edit-button')).toBeTruthy(); + }); }); }); diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx new file mode 100644 index 0000000000..4b9c0594d2 --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx @@ -0,0 +1,127 @@ +import React, { PropsWithChildren } from 'react'; +import { Text, View } from 'react-native'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { NotificationManager } from 'stream-chat'; +import type { Channel } 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 { 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'; + +jest.mock('../../../hooks/actions/useChannelActions'); +const mockedUseChannelActions = jest.mocked(useChannelActions); + +const EditDetailsProbe = () => ( + + edit-details + +); + +const buildChannel = (capabilities: string[] = []): Channel => + ({ + cid: 'messaging:test', + data: { own_capabilities: capabilities }, + on: () => ({ unsubscribe: () => undefined }), + state: { members: {} }, + }) as unknown as Channel; + +const Providers = ({ children }: PropsWithChildren) => ( + + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + {children} + + + + +); + +const renderEditButton = ({ + channel, + onEditChannelPress, +}: { + channel: Channel; + onEditChannelPress?: () => void; +}) => + render( + + + + + + + , + ); + +describe('ChannelDetailsEditButton', () => { + beforeEach(() => { + jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(false); + mockedUseChannelActions.mockReturnValue({ + updateImage: jest.fn(), + updateName: jest.fn(), + } as unknown as ReturnType); + }); + + afterEach(() => { + jest.restoreAllMocks(); + mockedUseChannelActions.mockReset(); + }); + + it('does not render the Edit button when the user lacks the update-channel capability', () => { + renderEditButton({ channel: buildChannel([]) }); + + expect(screen.queryByTestId('channel-details-edit-button')).toBeNull(); + }); + + it('renders the Edit button when the user has the update-channel capability', () => { + renderEditButton({ channel: buildChannel(['update-channel']) }); + + const button = screen.getByTestId('channel-details-edit-button'); + expect(button).toBeTruthy(); + 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); + + renderEditButton({ channel: buildChannel(['update-channel']) }); + + expect(screen.queryByTestId('channel-details-edit-button')).toBeNull(); + }); + + it('invokes onEditChannelPress when the Edit button is pressed', () => { + const onEditChannelPress = jest.fn(); + renderEditButton({ channel: buildChannel(['update-channel']), onEditChannelPress }); + + fireEvent.press(screen.getByTestId('channel-details-edit-button')); + + expect(onEditChannelPress).toHaveBeenCalledTimes(1); + }); + + it('opens the edit modal when the Edit button is pressed and onEditChannelPress is not provided', () => { + renderEditButton({ channel: buildChannel(['update-channel']) }); + + expect(screen.queryByTestId('channel-edit-details-probe')).toBeNull(); + + fireEvent.press(screen.getByTestId('channel-details-edit-button')); + + expect(screen.getByTestId('channel-edit-details-probe')).toBeTruthy(); + }); +}); diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavHeader.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavHeader.test.tsx index 0a148e5c2f..3cf340855d 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavHeader.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavHeader.test.tsx @@ -1,27 +1,22 @@ import React, { PropsWithChildren } from 'react'; import { Text, View } from 'react-native'; -import { fireEvent, render, screen } from '@testing-library/react-native'; +import { render, screen } from '@testing-library/react-native'; import { NotificationManager } from 'stream-chat'; import type { Channel } 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 { 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 { ChannelDetailsNavHeader } from '../components/ChannelDetailsNavHeader'; -jest.mock('../../../hooks/actions/useChannelActions'); -const mockedUseChannelActions = jest.mocked(useChannelActions); - -const EditDetailsProbe = () => ( - - edit-details +const ActionProbe = () => ( + + action ); @@ -54,77 +49,49 @@ const Providers = ({ children }: PropsWithChildren) => ( ); const renderHeader = ({ + action, channel, onBack, - onEditChannelPress, }: { + action?: React.ReactNode; channel: Channel; onBack?: () => void; - onEditChannelPress?: () => void; }) => render( - - - - - + + + , ); describe('ChannelDetailsNavHeader', () => { beforeEach(() => { jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(false); - mockedUseChannelActions.mockReturnValue({ - updateImage: jest.fn(), - updateName: jest.fn(), - } as unknown as ReturnType); }); afterEach(() => { jest.restoreAllMocks(); - mockedUseChannelActions.mockReset(); }); - it('does not render the Edit button when the user lacks the update-channel capability', () => { - renderHeader({ channel: buildChannel([]) }); + it('renders the action node passed via the action slot', () => { + renderHeader({ action: , channel: buildChannel([]) }); - expect(screen.queryByTestId('channel-details-edit-button')).toBeNull(); + expect(screen.getByTestId('channel-details-action-probe')).toBeTruthy(); }); - it('renders the Edit button when the user has the update-channel capability', () => { - renderHeader({ channel: buildChannel(['update-channel']) }); + it('resolves the group info title for a non-direct channel', () => { + renderHeader({ channel: buildChannel([]) }); - const button = screen.getByTestId('channel-details-edit-button'); - expect(button).toBeTruthy(); - expect(screen.getByText('Edit')).toBeTruthy(); + expect(screen.getByText('Group Info')).toBeTruthy(); }); - it('does not render the Edit button in a direct (1:1) channel even with the update-channel capability', () => { + it('resolves the contact info title for a direct (1:1) channel', () => { jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(true); - renderHeader({ channel: buildChannel(['update-channel']) }); - - expect(screen.queryByTestId('channel-details-edit-button')).toBeNull(); - }); - - it('invokes onEditChannelPress when the Edit button is pressed', () => { - const onEditChannelPress = jest.fn(); - renderHeader({ channel: buildChannel(['update-channel']), onEditChannelPress }); - - fireEvent.press(screen.getByTestId('channel-details-edit-button')); - - expect(onEditChannelPress).toHaveBeenCalledTimes(1); - }); - - it('opens the edit modal when the Edit button is pressed and onEditChannelPress is not provided', () => { - renderHeader({ channel: buildChannel(['update-channel']) }); - - expect(screen.queryByTestId('channel-edit-details-probe')).toBeNull(); - - fireEvent.press(screen.getByTestId('channel-details-edit-button')); + renderHeader({ channel: buildChannel([]) }); - expect(screen.getByTestId('channel-edit-details-probe')).toBeTruthy(); + expect(screen.getByText('Contact Info')).toBeTruthy(); }); it('renders the back button only when onBack is provided', () => { @@ -133,11 +100,9 @@ describe('ChannelDetailsNavHeader', () => { rerender( - - - - - + + + , ); diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavigationSection.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavigationSection.test.tsx index 1c8ac8f986..8c34e3735c 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavigationSection.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavigationSection.test.tsx @@ -1,7 +1,19 @@ import React from 'react'; +import { Modal } from 'react-native'; -import { render } from '@testing-library/react-native'; +import type { SharedValue } from 'react-native-reanimated'; +import { act, fireEvent, render } from '@testing-library/react-native'; + +import { + ChannelDetailsContextProvider, + type ChannelDetailsContextValue, +} from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { + type Overlay, + OverlayContext, + type OverlayContextValue, +} from '../../../contexts/overlayContext/OverlayContext'; import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; @@ -25,8 +37,45 @@ jest.mock('../components/ChannelDetailsActionItem', () => { }; }); -const renderSection = () => - render( +const pinnedListProbe: object[] = []; + +jest.mock('../components/navigation-section/PinnedMessageList', () => { + const ReactLib = require('react'); + const { Text: RNText } = require('react-native'); + return { + PinnedMessageList: (props: object) => { + pinnedListProbe.push(props); + return ReactLib.createElement(RNText, { testID: 'pinned-message-list' }, 'list'); + }, + }; +}); + +jest.mock('../components/navigation-section/MediaList', () => { + const ReactLib = require('react'); + const { Text: RNText } = require('react-native'); + return { + MediaList: () => ReactLib.createElement(RNText, { testID: 'media-list' }, 'media'), + }; +}); + +jest.mock('../../ImageGallery/ImageGallery', () => { + const ReactLib = require('react'); + const { Text: RNText } = require('react-native'); + return { + ImageGallery: () => ReactLib.createElement(RNText, { testID: 'image-gallery' }, 'gallery'), + }; +}); + +const renderSection = ( + contextValue: Partial = {}, + overlay: Overlay = 'none', +) => { + const overlayContextValue: OverlayContextValue = { + overlay, + overlayOpacity: { value: overlay === 'none' ? 0 : 1 } as SharedValue, + setOverlay: jest.fn(), + }; + return render( userLanguage: 'en', }} > - + + + + + , ); +}; describe('ChannelDetailsNavigationSection', () => { beforeEach(() => { probeCalls.length = 0; + pinnedListProbe.length = 0; }); it('renders the three navigation rows with their labels and testIDs', () => { @@ -60,14 +115,13 @@ describe('ChannelDetailsNavigationSection', () => { expect(probeCalls.map((p) => p.label)).toEqual(['Pinned Messages', 'Photos & Videos', 'Files']); }); - it('passes an Icon and a trailing chevron to every row and leaves them non-interactive', () => { + it('passes an Icon and a trailing chevron to every row', () => { renderSection(); expect(probeCalls).toHaveLength(3); probeCalls.forEach((props) => { expect(props.Icon).toBeTruthy(); expect(props.trailing).toBeTruthy(); - expect(props.onPress).toBeUndefined(); }); }); @@ -78,4 +132,169 @@ describe('ChannelDetailsNavigationSection', () => { expect(first).toBe(second); expect(second).toBe(third); }); + + describe('without a getNavigationItems prop (default mode)', () => { + it('makes every row interactive', () => { + renderSection(); + + const [pinned, photos, files] = probeCalls; + expect(pinned.onPress).toBeDefined(); + expect(photos.onPress).toBeDefined(); + expect(files.onPress).toBeDefined(); + }); + + it('renders a single modal that is hidden with no content until a section is selected', () => { + const { UNSAFE_getByType, queryByTestId } = renderSection(); + + expect(UNSAFE_getByType(Modal).props.visible).toBe(false); + expect(queryByTestId('pinned-message-list')).toBeNull(); + }); + + it('opens the modal with the pinned messages content when the pinned messages row is pressed', () => { + const { UNSAFE_getByType, getByTestId } = renderSection(); + + fireEvent.press(getByTestId('channel-details-pinned-messages')); + + expect(UNSAFE_getByType(Modal).props.visible).toBe(true); + expect(getByTestId('pinned-message-list')).toBeTruthy(); + }); + + it('opens an empty modal (no pinned list) for sections without a built-in screen', () => { + const { UNSAFE_getByType, getByTestId, queryByTestId } = renderSection(); + + fireEvent.press(getByTestId('channel-details-photos-and-videos')); + + expect(UNSAFE_getByType(Modal).props.visible).toBe(true); + expect(queryByTestId('pinned-message-list')).toBeNull(); + }); + + it('closes the modal and clears its content when the modal requests it', () => { + const { UNSAFE_getByType, getByTestId, queryByTestId } = renderSection(); + + fireEvent.press(getByTestId('channel-details-pinned-messages')); + expect(UNSAFE_getByType(Modal).props.visible).toBe(true); + + act(() => { + UNSAFE_getByType(Modal).props.onRequestClose(); + }); + + expect(UNSAFE_getByType(Modal).props.visible).toBe(false); + expect(queryByTestId('pinned-message-list')).toBeNull(); + }); + }); + + describe('image gallery overlay', () => { + it('renders the gallery above the media list when the overlay is set to "gallery"', () => { + const { getByTestId } = renderSection({}, 'gallery'); + + fireEvent.press(getByTestId('channel-details-photos-and-videos')); + + expect(getByTestId('media-list')).toBeTruthy(); + expect(getByTestId('image-gallery')).toBeTruthy(); + }); + + it('does not render the gallery while the overlay is "none"', () => { + const { getByTestId, queryByTestId } = renderSection({}, 'none'); + + fireEvent.press(getByTestId('channel-details-photos-and-videos')); + + expect(getByTestId('media-list')).toBeTruthy(); + expect(queryByTestId('image-gallery')).toBeNull(); + }); + + it('does not render the gallery for non-media sections even when the overlay is "gallery"', () => { + const { getByTestId, queryByTestId } = renderSection({}, 'gallery'); + + fireEvent.press(getByTestId('channel-details-pinned-messages')); + expect(queryByTestId('image-gallery')).toBeNull(); + + fireEvent.press(getByTestId('channel-details-files')); + expect(queryByTestId('image-gallery')).toBeNull(); + }); + }); + + describe('with a getNavigationItems prop', () => { + it('receives the built-in default items (section, label, Icon) and a context', () => { + const getNavigationItems = jest.fn(({ defaultItems }) => defaultItems); + renderSection({ getNavigationItems }); + + expect(getNavigationItems).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ t: expect.any(Function) }), + defaultItems: [ + expect.objectContaining({ + Icon: expect.any(Function), + label: 'Pinned Messages', + section: 'pinned-messages', + }), + expect.objectContaining({ label: 'Photos & Videos', section: 'photos-and-videos' }), + expect.objectContaining({ label: 'Files', section: 'files' }), + ], + }), + ); + // Default items carry no onPress; the section component supplies the open-modal behavior. + const { defaultItems } = getNavigationItems.mock.calls[0][0]; + expect( + defaultItems.every((item: { onPress?: () => void }) => item.onPress === undefined), + ).toBe(true); + }); + + it('renders exactly the items the customizer returns', () => { + const getNavigationItems = ({ defaultItems }: { defaultItems: { section: string }[] }) => + defaultItems.filter((item) => item.section === 'pinned-messages'); + const { getByTestId, queryByTestId } = renderSection({ + getNavigationItems: getNavigationItems as never, + }); + + expect(getByTestId('channel-details-pinned-messages')).toBeTruthy(); + expect(queryByTestId('channel-details-photos-and-videos')).toBeNull(); + expect(queryByTestId('channel-details-files')).toBeNull(); + }); + + it('runs a custom onPress instead of opening the built-in modal', () => { + const customOnPress = jest.fn(); + const getNavigationItems = ({ defaultItems }: { defaultItems: { onPress: () => void }[] }) => + defaultItems.map((item) => ({ ...item, onPress: customOnPress })); + const { UNSAFE_getByType, getByTestId } = renderSection({ + getNavigationItems: getNavigationItems as never, + }); + + fireEvent.press(getByTestId('channel-details-pinned-messages')); + + expect(customOnPress).toHaveBeenCalledTimes(1); + expect(UNSAFE_getByType(Modal).props.visible).toBe(false); + }); + + it('still opens the built-in modal when a row keeps its default onPress', () => { + const getNavigationItems = ({ defaultItems }: { defaultItems: unknown[] }) => defaultItems; + const { UNSAFE_getByType, getByTestId } = renderSection({ + getNavigationItems: getNavigationItems as never, + }); + + fireEvent.press(getByTestId('channel-details-pinned-messages')); + + expect(UNSAFE_getByType(Modal).props.visible).toBe(true); + expect(getByTestId('pinned-message-list')).toBeTruthy(); + }); + + it('renders consumer-added rows with custom section identifiers', () => { + const customOnPress = jest.fn(); + const getNavigationItems = ({ defaultItems }: { defaultItems: unknown[] }) => [ + ...defaultItems, + { + Icon: () => null, + label: 'My Custom Row', + onPress: customOnPress, + section: 'my-custom-section', + }, + ]; + const { getByTestId } = renderSection({ getNavigationItems: getNavigationItems as never }); + + const customRow = getByTestId('channel-details-my-custom-section'); + expect(customRow).toBeTruthy(); + + fireEvent.press(customRow); + expect(customOnPress).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/package/src/components/ChannelDetails/__tests__/useFileAttachmentListSections.test.tsx b/package/src/components/ChannelDetails/__tests__/useFileAttachmentListSections.test.tsx new file mode 100644 index 0000000000..e793c2c7de --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/useFileAttachmentListSections.test.tsx @@ -0,0 +1,159 @@ +import React, { type PropsWithChildren } from 'react'; + +import { renderHook } from '@testing-library/react-native'; + +import type { Attachment, MessageResponse } from 'stream-chat'; + +import { + TranslationProvider, + type TranslationContextValue, +} from '../../../contexts/translationContext/TranslationContext'; +import { + generateAudioAttachment, + generateFileAttachment, + generateImageAttachment, +} from '../../../mock-builders/generator/attachment'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { Streami18n } from '../../../utils/i18n/Streami18n'; +import { useFileAttachmentListSections } from '../hooks/useFileAttachmentListSections'; + +let translators: TranslationContextValue; + +beforeAll(async () => { + const i18nInstance = new Streami18n(); + translators = (await i18nInstance.getTranslators()) as unknown as TranslationContextValue; +}); + +const wrapper = ({ children }: PropsWithChildren) => ( + {children} +); + +const messageAt = ( + id: string, + createdAt: string, + attachments: Attachment[] = [generateFileAttachment()], +): MessageResponse => + generateMessage({ + attachments: attachments as never, + created_at: new Date(createdAt), + id, + }) as unknown as MessageResponse; + +describe('useFileAttachmentListSections', () => { + it('returns an empty array when there are no messages', () => { + const { result } = renderHook(() => useFileAttachmentListSections(undefined), { wrapper }); + expect(result.current).toEqual([]); + + const { result: emptyResult } = renderHook(() => useFileAttachmentListSections([]), { + wrapper, + }); + expect(emptyResult.current).toEqual([]); + }); + + it('gathers only file and audio attachments, skipping images', () => { + const message = messageAt('m-1', '2026-03-15T00:00:00.000Z', [ + generateFileAttachment({ title: 'a-file.pdf' }), + generateImageAttachment({ title: 'a-photo.png' }), + generateAudioAttachment({ title: 'a-clip.mp3' }), + ]); + + const { result } = renderHook(() => useFileAttachmentListSections([message]), { wrapper }); + + expect(result.current).toHaveLength(1); + expect(result.current[0].data.map((tile) => tile.attachment.title)).toEqual([ + 'a-file.pdf', + 'a-clip.mp3', + ]); + expect(result.current[0].data.every((tile) => tile.message === message)).toBe(true); + }); + + it('skips OG/scraped link-preview attachments', () => { + const message = messageAt('m-og', '2026-03-15T00:00:00.000Z', [ + generateFileAttachment({ title: 'a-file.pdf' }), + generateFileAttachment({ + og_scrape_url: 'https://example.com', + title: 'link-preview', + title_link: 'https://example.com', + }), + ]); + + const { result } = renderHook(() => useFileAttachmentListSections([message]), { wrapper }); + + expect(result.current[0].data.map((tile) => tile.attachment.title)).toEqual(['a-file.pdf']); + }); + + it('produces no section for a message without file or audio attachments', () => { + const message = messageAt('m-1', '2026-03-15T00:00:00.000Z', [generateImageAttachment()]); + + const { result } = renderHook(() => useFileAttachmentListSections([message]), { wrapper }); + + expect(result.current).toEqual([]); + }); + + it('groups messages into month sections in newest-first order', () => { + const february = messageAt('m-feb', '2026-02-10T00:00:00.000Z'); + const march = messageAt('m-mar', '2026-03-15T00:00:00.000Z'); + + // The search source returns messages newest-first; the hook only groups consecutive months. + const { result } = renderHook(() => useFileAttachmentListSections([march, february]), { + wrapper, + }); + + expect(result.current.map((section) => section.title)).toEqual(['March 2026', 'February 2026']); + expect(result.current[0].data.map((tile) => tile.message.id)).toEqual(['m-mar']); + expect(result.current[1].data.map((tile) => tile.message.id)).toEqual(['m-feb']); + }); + + it('keeps attachments from the same month under a single section', () => { + const early = messageAt('m-1', '2026-03-02T00:00:00.000Z'); + const late = messageAt('m-2', '2026-03-28T00:00:00.000Z'); + + // Provided newest-first, as the search source returns them. + const { result } = renderHook(() => useFileAttachmentListSections([late, early]), { wrapper }); + + expect(result.current).toHaveLength(1); + expect(result.current[0].title).toBe('March 2026'); + expect(result.current[0].data.map((tile) => tile.message.id)).toEqual(['m-2', 'm-1']); + }); + + it('emits one tile per file attachment within a message', () => { + const message = messageAt('m-1', '2026-03-15T00:00:00.000Z', [ + generateFileAttachment({ title: 'one.pdf' }), + generateFileAttachment({ title: 'two.pdf' }), + ]); + + const { result } = renderHook(() => useFileAttachmentListSections([message]), { wrapper }); + + expect(result.current[0].data.map((tile) => tile.attachment.title)).toEqual([ + 'one.pdf', + 'two.pdf', + ]); + }); + + it('formats the section title via the timestamp/FileAttachmentListSection translation key', () => { + const customTranslators = { + t: jest.fn((key: string) => + key === 'timestamp/FileAttachmentListSection' ? 'CUSTOM TITLE' : key, + ), + tDateTimeParser: translators.tDateTimeParser, + userLanguage: 'en', + } as unknown as TranslationContextValue; + + const customWrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + + const { result } = renderHook( + () => useFileAttachmentListSections([messageAt('m-1', '2026-03-15T00:00:00.000Z')]), + { wrapper: customWrapper }, + ); + + expect(result.current[0].title).toBe('CUSTOM TITLE'); + // The MMMM YYYY format lives in the translation string itself, so the hook only forwards + // the timestamp to `t` — it does not pass a `format` option. + expect(customTranslators.t).toHaveBeenCalledWith( + 'timestamp/FileAttachmentListSection', + expect.objectContaining({ timestamp: expect.any(Date) }), + ); + }); +}); diff --git a/package/src/components/ChannelDetails/__tests__/useMediaList.test.tsx b/package/src/components/ChannelDetails/__tests__/useMediaList.test.tsx new file mode 100644 index 0000000000..16de9861f1 --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/useMediaList.test.tsx @@ -0,0 +1,53 @@ +import { renderHook } from '@testing-library/react-native'; + +import type { MessageResponse } from 'stream-chat'; + +import { + generateImageAttachment, + generateVideoAttachment, +} from '../../../mock-builders/generator/attachment'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { useMediaList } from '../hooks/useMediaList'; + +const messageWithAttachments = (id: string, attachments: unknown[]): MessageResponse => + generateMessage({ attachments: attachments as never, id }) as unknown as MessageResponse; + +describe('useMediaList', () => { + it('returns an empty array when there are no messages', () => { + const { result } = renderHook(() => useMediaList(undefined)); + expect(result.current).toEqual([]); + + const { result: emptyResult } = renderHook(() => useMediaList([])); + expect(emptyResult.current).toEqual([]); + }); + + it('returns one tile per image/video attachment and skips non-media and scraped attachments', () => { + const messageA = messageWithAttachments('m-1', [ + generateImageAttachment(), + generateVideoAttachment(), + ]); + const messageB = messageWithAttachments('m-2', [ + // excluded: image used as a link preview / og scrape + generateImageAttachment({ og_scrape_url: 'https://example.com' }), + generateImageAttachment({ title_link: 'https://example.com' }), + // excluded: not media + { type: 'file' }, + // included + generateImageAttachment(), + ]); + + const { result } = renderHook(() => useMediaList([messageA, messageB])); + + expect(result.current.map((tile) => `${tile.message.id}-${tile.attachment.type}`)).toEqual([ + 'm-1-image', + 'm-1-video', + 'm-2-image', + ]); + }); + + it('skips messages without attachments', () => { + const message = generateMessage({ id: 'm-1' }) as unknown as MessageResponse; + const { result } = renderHook(() => useMediaList([message])); + expect(result.current).toEqual([]); + }); +}); diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx new file mode 100644 index 0000000000..8266ecf066 --- /dev/null +++ b/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx @@ -0,0 +1,50 @@ +import React, { useCallback, useState } from 'react'; + +import { ChannelEditDetailsModal } from './ChannelEditDetailsModal'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useChannelOwnCapabilities } from '../../../hooks/useChannelOwnCapabilities'; +import { useIsDirectChat } from '../../../hooks/useIsDirectChat'; +import { Button } from '../../ui/Button/Button'; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelDetailsEditButton = () => { + const { channel, onEditChannelPress } = useChannelDetailsContext(); + const { t } = useTranslationContext(); + const ownCapabilities = useChannelOwnCapabilities(channel); + const canUpdateChannel = ownCapabilities?.includes('update-channel') ?? false; + const isDirect = useIsDirectChat(channel); + const [editModalVisible, setEditModalVisible] = useState(false); + + const handleEditPress = useCallback(() => { + if (onEditChannelPress) { + onEditChannelPress(); + return; + } + setEditModalVisible(true); + }, [onEditChannelPress]); + + const handleEditModalClose = useCallback(() => setEditModalVisible(false), []); + + if (!canUpdateChannel || isDirect) { + return null; + } + + return ( + <> +