From be62a6beba6f9ba874731c62e5201204aa898f75 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 11 Jun 2026 08:44:20 -0500 Subject: [PATCH 01/19] feat: add EmptyScreen component --- .../src/components/UIComponents/EmptyList.tsx | 80 +++++++++++++++++ .../UIComponents/__tests__/EmptyList.test.tsx | 90 +++++++++++++++++++ package/src/components/UIComponents/index.ts | 1 + .../src/contexts/themeContext/utils/theme.ts | 10 +++ 4 files changed, 181 insertions(+) create mode 100644 package/src/components/UIComponents/EmptyList.tsx create mode 100644 package/src/components/UIComponents/__tests__/EmptyList.test.tsx diff --git a/package/src/components/UIComponents/EmptyList.tsx b/package/src/components/UIComponents/EmptyList.tsx new file mode 100644 index 0000000000..f7de733374 --- /dev/null +++ b/package/src/components/UIComponents/EmptyList.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { I18nManager, StyleSheet, Text, View } from 'react-native'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { IconProps } from '../../icons/utils/base'; +import { primitives } from '../../theme'; + +export type EmptyListProps = { + /** + * Icon component to render. Its size and color are set by `EmptyList`. + */ + icon: React.ComponentType; + /** + * Title text shown below the icon. + */ + title: string; + /** + * Optional supporting text shown below the title. + */ + subtitle?: string; +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const EmptyList = ({ icon: Icon, subtitle, title }: EmptyListProps) => { + const { + theme: { + emptyList: { container, subtitle: subtitleStyle, title: titleStyle }, + semantics, + }, + } = useTheme(); + + return ( + + + + {title} + {subtitle ? ( + + {subtitle} + + ) : null} + + + ); +}; + +EmptyList.displayName = 'EmptyList{emptyList}'; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + gap: primitives.spacingSm, + height: '100%', + justifyContent: 'center', + paddingHorizontal: primitives.spacingMd, + paddingVertical: primitives.spacing3xl, + width: '100%', + }, + content: { + alignItems: 'center', + gap: primitives.spacingXs, + width: '100%', + }, + subtitle: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + textAlign: 'center', + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + title: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + textAlign: 'center', + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, +}); diff --git a/package/src/components/UIComponents/__tests__/EmptyList.test.tsx b/package/src/components/UIComponents/__tests__/EmptyList.test.tsx new file mode 100644 index 0000000000..8997452072 --- /dev/null +++ b/package/src/components/UIComponents/__tests__/EmptyList.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react-native'; + +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { Pin } from '../../../icons/pin'; +import { EmptyList } from '../EmptyList'; + +type EmptyListProps = React.ComponentProps; + +const renderComponent = (props: Partial = {}) => + render( + + + , + ); + +describe('EmptyList', () => { + it('renders the empty-list testID', () => { + renderComponent(); + expect(screen.getByTestId('empty-list')).toBeTruthy(); + }); + + it('renders the title text', () => { + renderComponent({ title: 'Nothing here' }); + expect(screen.getByText('Nothing here')).toBeTruthy(); + }); + + it('renders the subtitle when provided', () => { + renderComponent({ subtitle: 'Long-press a message to pin it to the chat' }); + expect(screen.getByText('Long-press a message to pin it to the chat')).toBeTruthy(); + }); + + it('does not render a subtitle when omitted', () => { + renderComponent({ subtitle: undefined }); + expect(screen.queryByText('Long-press a message to pin it to the chat')).toBeNull(); + }); + + it('honors a custom container style override from the theme', () => { + const customTheme = { + ...defaultTheme, + emptyList: { + container: { backgroundColor: 'rgb(255, 0, 0)' }, + subtitle: {}, + title: {}, + }, + }; + + render( + + + , + ); + + const container = screen.getByTestId('empty-list'); + const flattened = Array.isArray(container.props.style) + ? Object.assign({}, ...container.props.style.flat(Infinity).filter(Boolean)) + : container.props.style; + expect(flattened.backgroundColor).toBe('rgb(255, 0, 0)'); + }); + + it('honors custom title and subtitle style overrides from the theme', () => { + const customTheme = { + ...defaultTheme, + emptyList: { + container: {}, + subtitle: { color: 'rgb(0, 0, 255)' }, + title: { color: 'rgb(0, 255, 0)' }, + }, + }; + + render( + + + , + ); + + const flatten = (node: { props: { style: unknown } }) => + Array.isArray(node.props.style) + ? Object.assign({}, ...(node.props.style as unknown[]).flat(Infinity).filter(Boolean)) + : node.props.style; + + const title = screen.getByText('Title') as unknown as { props: { style: unknown } }; + const subtitle = screen.getByText('Subtitle') as unknown as { props: { style: unknown } }; + + expect((flatten(title) as { color?: string }).color).toBe('rgb(0, 255, 0)'); + expect((flatten(subtitle) as { color?: string }).color).toBe('rgb(0, 0, 255)'); + }); +}); diff --git a/package/src/components/UIComponents/index.ts b/package/src/components/UIComponents/index.ts index f08b5c01c6..23bac781aa 100644 --- a/package/src/components/UIComponents/index.ts +++ b/package/src/components/UIComponents/index.ts @@ -1,5 +1,6 @@ export * from './BottomSheetModal'; export * from './StreamBottomSheetModalFlatList'; +export * from './EmptyList'; export * from './EmptySearchResult'; export * from './ImageBackground'; export * from './SearchInput'; diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 49dea2bfa1..87556848e5 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -334,6 +334,11 @@ export type Theme = { container: ViewStyle; text: TextStyle; }; + emptyList: { + container: ViewStyle; + subtitle: TextStyle; + title: TextStyle; + }; emptySearchResult: { container: ViewStyle; text: TextStyle; @@ -1379,6 +1384,11 @@ export const defaultTheme: Theme = { container: {}, text: {}, }, + emptyList: { + container: {}, + subtitle: {}, + title: {}, + }, emptySearchResult: { container: {}, text: {}, From 26e5aaf92bf9b694e8e094c75f8a0774206acac5 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 11 Jun 2026 12:09:03 -0500 Subject: [PATCH 02/19] refactor: nav header --- .../ChannelDetails/ChannelDetails.tsx | 3 +- .../ChannelDetailsEditButton.test.tsx | 127 ++++++++++++++++++ .../ChannelDetailsNavHeader.test.tsx | 77 +++-------- .../components/ChannelDetailsEditButton.tsx | 50 +++++++ .../components/ChannelDetailsNavHeader.tsx | 39 +----- .../ChannelDetails/components/index.ts | 1 + .../componentsContext/defaultComponents.ts | 2 + 7 files changed, 209 insertions(+), 90 deletions(-) create mode 100644 package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx create mode 100644 package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx diff --git a/package/src/components/ChannelDetails/ChannelDetails.tsx b/package/src/components/ChannelDetails/ChannelDetails.tsx index 2c5ab6bc3f..c14c6de42e 100644 --- a/package/src/components/ChannelDetails/ChannelDetails.tsx +++ b/package/src/components/ChannelDetails/ChannelDetails.tsx @@ -112,6 +112,7 @@ export const ChannelDetailsContent = () => { } = useTheme(); const { ChannelDetailsActionsSection, + ChannelDetailsEditButton, ChannelDetailsMemberSection, ChannelDetailsNavigationSection, ChannelDetailsProfile, @@ -128,7 +129,7 @@ export const ChannelDetailsContent = () => { containerOverride, ]} > - + } /> {ChannelDetailsNavigationSection ? : null} 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/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 ( + <> +