diff --git a/.claude/skills/accessibility/SKILL.md b/.claude/skills/accessibility/SKILL.md index ab32176cce..d815550568 100644 --- a/.claude/skills/accessibility/SKILL.md +++ b/.claude/skills/accessibility/SKILL.md @@ -10,7 +10,7 @@ Use this skill whenever code changes can affect screen-reader users (VoiceOver o ## Non-negotiable rules 1. **Native semantics first.** Use `Pressable`, `TextInput`, `Switch`, `Image` directly. Use `accessibilityRole` only when native semantics cannot represent the widget (`menu`, `menuitem`, `progressbar`, `radio`, `checkbox`, `article`, `alert`, `tablist`, `tab`). -2. **Never hardcode English** in `accessibilityLabel`/`accessibilityHint`/announcement strings. For SDK `Button`, pass `accessibilityLabelKey='a11y/...'` (and `accessibilityLabelParams` when needed). For non-Button components, use `useA11yLabel('a11y/...', params)` or `t('a11y/...')` directly when you don't need the disabled-state short-circuit. Add the key to all 12 locale files in `package/src/i18n/`. +2. **Never hardcode English** in `accessibilityLabel`/`accessibilityHint`/announcement strings. For SDK `Button`, pass `accessibilityLabelKey='a11y/...'` (and `accessibilityLabelParams` when needed). For non-Button components, use `useA11yLabel('a11y/...', params)` or `t('a11y/...')` directly when you don't need the disabled-state short-circuit. Add the key to all 12 locale files in `package/src/i18n/`. You can omit a11y keys if a button contains a text label that describes what it does. 3. **Gate behavior on `useAccessibilityContext().enabled`.** A11y is opt-in. New listeners, subscriptions, and announcer mounts must be no-ops when `enabled` is false. New `accessibilityRole`/`accessibilityState` props are fine to render unconditionally — they cost ~zero. 4. **One focusable target per action.** Don't nest `Pressable` inside `Pressable`. Mark inner decorative views with `accessibilityElementsHidden` (iOS) + `importantForAccessibility='no-hide-descendants'` (Android) so the parent carries the label. 5. **Decorative visuals stay hidden from AT.** Icon-only buttons must carry an `accessibilityLabel` on the wrapper, and the SVG icon should be hidden. @@ -68,6 +68,7 @@ const a11yProps = useResolvedModalAccessibilityProps(); ``` This returns: + - iOS: `{ accessibilityViewIsModal: true }` - Android: `{ importantForAccessibility: 'yes' }` - Either platform when `enabled` is false: `{}` @@ -81,9 +82,7 @@ Mobile gestures (long-press, hold-to-record, pinch/pan) must have a tap-equivale ```tsx const { audioRecorderTapMode } = useAccessibilityContext(); const screenReaderOn = useScreenReaderEnabled(); -const useTapMode = - audioRecorderTapMode === 'always' || - (audioRecorderTapMode === 'auto' && screenReaderOn); +const useTapMode = audioRecorderTapMode === 'always' || (audioRecorderTapMode === 'auto' && screenReaderOn); ``` Three-state semantics: `'auto'` (swap when SR is on), `'always'` (swap for everyone), `'never'` (integrator handles). @@ -109,10 +108,12 @@ Disable spring animations and limit fade durations when this is true. ## Testing requirements per change Minimum: + - Unit tests for new keyboard/focus/semantics behavior in nearest `__tests__/`. - Use `@testing-library/react-native` semantic queries: `getByRole`, `getByLabelText`, `getByA11yState`, `getByA11yValue`. Recommended for non-trivial changes: + - Render with `` and assert the accessible variant renders. - Render with `` and assert the legacy behavior is unchanged (no extra buttons, no listeners). @@ -143,6 +144,7 @@ Recommended for non-trivial changes: ## Cross-SDK parity API shapes mirror [`stream-chat-react#3146`](https://github.com/GetStream/stream-chat-react/pull/3146): + - `useAccessibilityAnnouncer` ≈ React's `useAriaLiveAnnouncer` - `useIncomingMessageAnnouncements` — same params, same throttle/batch logic - `a11y/*` i18n namespace shared diff --git a/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx b/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx index 182c2ec708..c704ecf4f7 100644 --- a/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx +++ b/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx @@ -1,403 +1,41 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { I18nManager, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native'; - -import { SafeAreaView } from 'react-native-safe-area-context'; - -import { RouteProp, useNavigation } from '@react-navigation/native'; +import React, { useCallback } from 'react'; +import type { RouteProp } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import type { ChannelMemberResponse, UserResponse } from 'stream-chat'; -import { - ChannelAvatar, - useChannelPreviewDisplayName, - useIsChannelMuted, - useOverlayContext, - useTheme, - Pin, -} from 'stream-chat-react-native'; +import { ChannelDetailsScreen } from 'stream-chat-react-native'; -import { AddMembersBottomSheet } from '../components/AddMembersBottomSheet'; -import { AllMembersBottomSheet } from '../components/AllMembersBottomSheet'; -import { ChannelDetailProfileSection } from '../components/ChannelDetailProfileSection'; -import { ConfirmationBottomSheet } from '../components/ConfirmationBottomSheet'; -import { ContactDetailBottomSheet } from '../components/ContactDetailBottomSheet'; -import { EditGroupBottomSheet } from '../components/EditGroupBottomSheet'; -import { ListItem } from '../components/ListItem'; -import { MemberListItem } from '../components/MemberListItem'; -import { ScreenHeader } from '../components/ScreenHeader'; -import { SectionCard } from '../components/SectionCard'; -import { useAppContext } from '../context/AppContext'; -import { File } from '../icons/File'; -import { GoForward } from '../icons/GoForward'; -import { LeaveGroup } from '../icons/LeaveGroup'; -import { Mute } from '../icons/Mute'; -import { Picture } from '../icons/Picture'; import type { StackNavigatorParamList } from '../types'; -import { useRtlMirrorSwitchStyle } from '../utils/rtlMirrorSwitchStyle'; - -const MAX_VISIBLE_MEMBERS = 5; type GroupChannelDetailsRouteProp = RouteProp; -type GroupChannelDetailsProps = { - route: GroupChannelDetailsRouteProp; -}; - type GroupChannelDetailsScreenNavigationProp = NativeStackNavigationProp< StackNavigatorParamList, 'GroupChannelDetailsScreen' >; +type GroupChannelDetailsProps = { + navigation: GroupChannelDetailsScreenNavigationProp; + route: GroupChannelDetailsRouteProp; +}; + export const GroupChannelDetailsScreen: React.FC = ({ + navigation, route: { params: { channel }, }, }) => { - const { chatClient } = useAppContext(); - const navigation = useNavigation(); - const { setOverlay } = useOverlayContext(); - const { - theme: { semantics }, - } = useTheme(); - const rtlMirrorSwitchStyle = useRtlMirrorSwitchStyle(); - const { muted: channelMuted } = useIsChannelMuted(channel); - - const [muted, setMuted] = useState( - chatClient?.mutedChannels.some((mute) => mute.channel?.id === channel?.id), - ); - const [allMembersVisible, setAllMembersVisible] = useState(false); - const [addMembersVisible, setAddMembersVisible] = useState(false); - const [confirmationVisible, setConfirmationVisible] = useState(false); - const [editVisible, setEditVisible] = useState(false); - const [selectedMember, setSelectedMember] = useState(null); - - const displayName = useChannelPreviewDisplayName(channel, 30); - const allMembers = useMemo(() => Object.values(channel.state.members), [channel.state.members]); - const memberCount = channel?.data?.member_count ?? allMembers.length; - const onlineCount = channel.state.watcher_count ?? 0; - - const memberStatusText = useMemo(() => { - const parts = [`${memberCount} members`]; - if (onlineCount > 0) { - parts.push(`${onlineCount} online`); - } - return parts.join(' · '); - }, [memberCount, onlineCount]); - - const visibleMembers = useMemo(() => allMembers.slice(0, MAX_VISIBLE_MEMBERS), [allMembers]); - const hasMoreMembers = allMembers.length > MAX_VISIBLE_MEMBERS; - - const channelCreatorId = - channel.data && (channel.data.created_by_id || (channel.data.created_by as UserResponse)?.id); - - const leaveGroup = useCallback(async () => { - if (chatClient?.user?.id) { - await channel.removeMembers([chatClient.user.id]); - } - setOverlay('none'); - navigation.reset({ - index: 0, - routes: [{ name: 'MessagingScreen' }], - }); - }, [channel, chatClient?.user?.id, navigation, setOverlay]); - - const openLeaveGroupConfirmationSheet = useCallback(() => { - if (!chatClient?.user?.id) { - return; - } - setConfirmationVisible(true); - }, [chatClient?.user?.id]); - - const closeConfirmation = useCallback(() => { - setConfirmationVisible(false); - }, []); - - const openAddMembersSheet = useCallback(() => { - if (!chatClient?.user?.id) return; - setAddMembersVisible(true); - }, [chatClient?.user?.id]); - - const openAddMembersFromAllMembers = useCallback(() => { - if (!chatClient?.user?.id) return; - setAllMembersVisible(false); - setAddMembersVisible(true); - }, [chatClient?.user?.id]); - - const closeAddMembers = useCallback(() => { - setAddMembersVisible(false); - }, []); - - const handleMuteToggle = useCallback(async () => { - if (muted) { - await channel.unmute(); - } else { - await channel.mute(); - } - setMuted((prev) => !prev); - }, [channel, muted]); - - const navigateToPinnedMessages = useCallback(() => { - navigation.navigate('ChannelPinnedMessagesScreen', { channel }); - }, [channel, navigation]); - - const navigateToImages = useCallback(() => { - navigation.navigate('ChannelImagesScreen', { channel }); - }, [channel, navigation]); - - const navigateToFiles = useCallback(() => { - navigation.navigate('ChannelFilesScreen', { channel }); - }, [channel, navigation]); - - const handleMemberPress = useCallback( - (member: ChannelMemberResponse) => { - if (member.user?.id !== chatClient?.user?.id) { - setSelectedMember(member); - } - }, - [chatClient?.user?.id], - ); - - const closeContactDetail = useCallback(() => { - setSelectedMember(null); - }, []); - - const isCreator = channelCreatorId === chatClient?.user?.id; - - const openAllMembers = useCallback(() => { - setAllMembersVisible(true); - }, []); - - const closeAllMembers = useCallback(() => { - setAllMembersVisible(false); - }, []); - - const openEditSheet = useCallback(() => { - setEditVisible(true); - }, []); - - const closeEditSheet = useCallback(() => { - setEditVisible(false); - }, []); - - const rightContent = useMemo( - () => ( - - Edit - - ), - [openEditSheet, semantics.borderCoreDefault, semantics.textPrimary], + const onBack = useCallback(() => navigation.goBack(), [navigation]); + const popToRoot = useCallback( + () => + navigation.reset({ + index: 0, + routes: [{ name: 'MessagingScreen' }], + }), + [navigation], ); - if (!channel) { - return null; - } - - const chevronRight = ; - return ( - - rightContent} /> - - } - muted={channelMuted} - title={displayName} - subtitle={memberStatusText} - /> - - - } - label='Pinned Messages' - trailing={chevronRight} - onPress={navigateToPinnedMessages} - /> - } - label='Photos & Videos' - trailing={chevronRight} - onPress={navigateToImages} - /> - } - label='Files' - trailing={chevronRight} - onPress={navigateToFiles} - /> - - - - - - {`${memberCount} members`} - - {isCreator ? ( - - - Add - - - ) : null} - - - {visibleMembers.map((member) => { - if (!member.user?.id) { - return null; - } - return ( - handleMemberPress(member)} - /> - ); - })} - - {hasMoreMembers ? ( - - - - View all - - - - ) : null} - - - - } - label='Mute Group' - trailing={ - - } - /> - } - label='Leave Group' - destructive - onPress={openLeaveGroupConfirmationSheet} - /> - - - - - - - - + ); }; - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - scrollContent: { - gap: 16, - paddingBottom: 40, - paddingHorizontal: 16, - paddingTop: 32, - }, - membersCard: { - paddingVertical: 0, - }, - sectionHeader: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingTop: 8, - }, - sectionHeaderTitle: { - flex: 1, - fontSize: 17, - fontWeight: '600', - lineHeight: 20, - writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', - }, - memberList: { - paddingBottom: 12, - }, - sectionFooter: { - alignItems: 'center', - borderTopWidth: 1, - paddingHorizontal: 16, - }, - viewAllButton: { - alignItems: 'center', - justifyContent: 'center', - minHeight: 48, - width: '100%', - }, - viewAllLabel: { - fontSize: 17, - fontWeight: '600', - lineHeight: 20, - }, - outlineButton: { - alignItems: 'center', - borderRadius: 9999, - borderWidth: 1, - justifyContent: 'center', - minHeight: 40, - paddingHorizontal: 16, - paddingVertical: 10, - }, - outlineButtonSm: { - alignItems: 'center', - borderRadius: 9999, - borderWidth: 1, - justifyContent: 'center', - minHeight: 32, - paddingHorizontal: 16, - paddingVertical: 6, - }, - outlineButtonLabel: { - fontSize: 17, - fontWeight: '600', - lineHeight: 20, - }, -}); diff --git a/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx b/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx index 30bb7caf70..29c170d2a8 100644 --- a/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx +++ b/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx @@ -1,32 +1,11 @@ -import React, { useCallback, useState } from 'react'; -import { ScrollView, StyleSheet, Switch } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import React, { useCallback } from 'react'; import type { RouteProp } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { - ChannelAvatar, - CircleBan, - Delete, - useChannelMuteActive, - Pin, - useTheme, -} from 'stream-chat-react-native'; +import { ChannelDetailsScreen } from 'stream-chat-react-native'; -import { ChannelDetailProfileSection } from '../components/ChannelDetailProfileSection'; -import { ConfirmationBottomSheet } from '../components/ConfirmationBottomSheet'; -import { ListItem } from '../components/ListItem'; -import { ScreenHeader } from '../components/ScreenHeader'; -import { SectionCard } from '../components/SectionCard'; -import { useAppContext } from '../context/AppContext'; -import { File } from '../icons/File'; -import { GoForward } from '../icons/GoForward'; -import { Mute } from '../icons/Mute'; -import { Picture } from '../icons/Picture'; import type { StackNavigatorParamList } from '../types'; -import { getUserActivityStatus } from '../utils/getUserActivityStatus'; -import { useRtlMirrorSwitchStyle } from '../utils/rtlMirrorSwitchStyle'; type OneOnOneChannelDetailScreenRouteProp = RouteProp< StackNavigatorParamList, @@ -49,199 +28,15 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ params: { channel }, }, }) => { - const { - theme: { semantics }, - } = useTheme(); - const rtlMirrorSwitchStyle = useRtlMirrorSwitchStyle(); - const { chatClient } = useAppContext(); - const userMuted = useChannelMuteActive(channel); - - const [confirmationVisible, setConfirmationVisible] = useState(false); - const [blockUserConfirmationVisible, setBlockUserConfirmationVisible] = useState(false); - - const member = Object.values(channel.state.members).find( - (channelMember) => channelMember.user?.id !== chatClient?.user?.id, - ); - - const user = member?.user; - const [muted, setMuted] = useState( - chatClient?.mutedUsers && - chatClient.mutedUsers.findIndex((mutedUser) => mutedUser.target.id === user?.id) > -1, - ); - - const deleteConversation = useCallback(async () => { - try { - await channel.delete(); - navigation.reset({ - index: 0, - routes: [{ name: 'MessagingScreen' }], - }); - } catch (error) { - console.error('Error deleting conversation', error); - } - }, [channel, navigation]); - - const handleBlockUser = useCallback(async () => { - try { - if (!user?.id) { - return; - } - await chatClient?.blockUser(user.id); + const onBack = useCallback(() => navigation.goBack(), [navigation]); + const popToRoot = useCallback( + () => navigation.reset({ index: 0, routes: [{ name: 'MessagingScreen' }], - }); - } catch (error) { - console.error('Error blocking user', error); - } - }, [chatClient, navigation, user?.id]); - - const openDeleteConversationConfirmationSheet = useCallback(() => { - if (!chatClient?.user?.id) { - return; - } - setConfirmationVisible(true); - }, [chatClient?.user?.id]); - - const openBlockUserConfirmationSheet = useCallback(() => { - if (!user?.id) { - return; - } - setBlockUserConfirmationVisible(true); - }, [user?.id]); - - const closeConfirmation = useCallback(() => { - setConfirmationVisible(false); - }, []); - - const closeBlockUserConfirmation = useCallback(() => { - setBlockUserConfirmationVisible(false); - }, []); - - const handleMuteToggle = useCallback(async () => { - if (muted) { - await chatClient?.unmuteUser(user!.id); - } else { - await chatClient?.muteUser(user!.id); - } - setMuted((prev) => !prev); - }, [chatClient, muted, user]); - - const navigateToPinnedMessages = useCallback(() => { - navigation.navigate('ChannelPinnedMessagesScreen', { channel }); - }, [channel, navigation]); - - const navigateToImages = useCallback(() => { - navigation.navigate('ChannelImagesScreen', { channel }); - }, [channel, navigation]); - - const navigateToFiles = useCallback(() => { - navigation.navigate('ChannelFilesScreen', { channel }); - }, [channel, navigation]); - - if (!user) { - return null; - } - - const activityStatus = getUserActivityStatus(user); - const chevronRight = ; - - return ( - - - - } - muted={userMuted} - title={user.name || user.id} - subtitle={activityStatus} - /> - - - } - label='Pinned Messages' - trailing={chevronRight} - onPress={navigateToPinnedMessages} - /> - } - label='Photos & Videos' - trailing={chevronRight} - onPress={navigateToImages} - /> - } - label='Files' - trailing={chevronRight} - onPress={navigateToFiles} - /> - - - - } - label='Mute User' - trailing={ - - } - /> - } - label='Block User' - onPress={openBlockUserConfirmationSheet} - /> - - } - label='Delete Conversation' - destructive - onPress={openDeleteConversationConfirmationSheet} - /> - - - - - + }), + [navigation], ); -}; -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - scrollContent: { - gap: 16, - paddingBottom: 40, - paddingHorizontal: 16, - paddingTop: 32, - }, -}); + return ; +}; diff --git a/package/src/components/ChannelDetailsScreen/ChannelDetailsScreen.tsx b/package/src/components/ChannelDetailsScreen/ChannelDetailsScreen.tsx new file mode 100644 index 0000000000..b7daf157ba --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/ChannelDetailsScreen.tsx @@ -0,0 +1,116 @@ +import React, { useMemo } from 'react'; +import { ScrollView, StyleSheet, View } from 'react-native'; + +import type { Channel } from 'stream-chat'; + +import { + ChannelDetailsContextProvider, + type ChannelDetailsContextValue, +} from '../../contexts/channelDetailsContext/channelDetailsContext'; +import { useChannelDetailsContext } from '../../contexts/channelDetailsContext/channelDetailsContext'; +import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; +import { OwnCapabilitiesProvider } from '../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useIsDirectChat } from '../../hooks/useIsDirectChat'; +import { primitives } from '../../theme'; + +import { useCreateOwnCapabilitiesContext } from '../Channel/hooks/useCreateOwnCapabilitiesContext'; + +export type ChannelDetailsScreenProps = { + channel: Channel; + /** + * Fired when the user taps the add-members button in the all-members bottom sheet. + * The button is shown whenever the current user has the `update-channel-members` + * capability; until this callback is provided the press is a no-op. + */ + onAddMembersPress?: () => void; + onBack?: () => void; + /** Fired after the channel is no longer available to the current user (delete or leave). */ + onChannelDismiss?: () => void; + /** + * Override for the default "View all" members behavior. When provided, the member + * section calls this callback instead of opening the built-in bottom-sheet — use it + * to navigate to a dedicated members screen instead. + */ + onViewAllMembersPress?: () => void; +}; + +export const ChannelDetailsScreenContent = () => { + const { channel } = useChannelDetailsContext(); + const { + theme: { + channelDetailsScreen: { container: containerOverride, scrollContent: scrollContentOverride }, + semantics, + }, + } = useTheme(); + const { + ChannelDetailsActionsSection, + ChannelDetailsMemberSection, + ChannelDetailsNavigationSection, + ChannelDetailsProfile, + ChannelDetailsScreenHeader, + } = useComponentsContext(); + const isDirect = useIsDirectChat(channel); + const styles = useStyles(); + + return ( + + + + + + {isDirect ? null : } + + + + ); +}; + +export const ChannelDetailsScreen = ({ + channel, + onAddMembersPress, + onBack, + onChannelDismiss, + onViewAllMembersPress, +}: ChannelDetailsScreenProps) => { + const { ChannelDetailsScreenContent: ChannelDetailsScreenContentOverride } = + useComponentsContext(); + const value = useMemo( + () => ({ channel, onAddMembersPress, onBack, onChannelDismiss, onViewAllMembersPress }), + [channel, onAddMembersPress, onBack, onChannelDismiss, onViewAllMembersPress], + ); + const ownCapabilitiesContext = useCreateOwnCapabilitiesContext({ channel }); + const Content = ChannelDetailsScreenContentOverride ?? ChannelDetailsScreenContent; + + return ( + + + + + + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + container: { + flex: 1, + }, + scrollContent: { + gap: primitives.spacingMd, + paddingBottom: primitives.spacing3xl, + paddingHorizontal: primitives.spacingMd, + paddingTop: primitives.spacing2xl, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsActionsSection.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsActionsSection.test.tsx new file mode 100644 index 0000000000..e7374a8a8b --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsActionsSection.test.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import { AccessibilityProvider } from '../../../contexts/accessibilityContext/AccessibilityContext'; +import { ChannelDetailsContextProvider } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { 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 type { ChannelActionItem } from '../../../hooks/useChannelActionItems'; +import * as useIsDirectChatModule from '../../../hooks/useIsDirectChat'; +import { ChannelDetailsActionsSection } from '../components/ChannelDetailsActionsSection'; +import type { ChannelDetailsListItemProps } from '../components/ChannelDetailsListItem'; +import * as useChannelDetailsActionItemsModule from '../hooks/useChannelDetailsActionItems'; + +const NoopIcon = () => null; + +const buildItem = (overrides: Partial = {}): ChannelActionItem => ({ + action: jest.fn(), + Icon: NoopIcon, + id: 'mute', + label: 'Mute', + placement: 'sheet', + type: 'standard', + ...overrides, +}); + +const channel = { cid: 'messaging:test' } as unknown as Channel; + +type Probe = ChannelDetailsListItemProps & { testID?: string }; + +const probeCalls: Probe[] = []; +const ListItemProbe = (props: Probe) => { + probeCalls.push(props); + return ( + + {props.label} + + ); +}; + +const renderSection = ({ a11yEnabled = false }: { a11yEnabled?: boolean } = {}) => + render( + + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + + + + + + + , + ); + +describe('ChannelDetailsActionsSection', () => { + let useIsDirectChatSpy: jest.SpyInstance; + let useActionItemsSpy: jest.SpyInstance; + + beforeEach(() => { + probeCalls.length = 0; + useIsDirectChatSpy = jest + .spyOn(useIsDirectChatModule, 'useIsDirectChat') + .mockReturnValue(false); + useActionItemsSpy = jest + .spyOn(useChannelDetailsActionItemsModule, 'useChannelDetailsActionItems') + .mockReturnValue([]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('when there are no items', () => { + it('renders nothing', () => { + const { toJSON } = renderSection(); + expect(toJSON()).toBeNull(); + }); + }); + + describe('when there are items', () => { + const muteItem = buildItem({ id: 'mute', label: 'Mute Group' }); + const leaveItem = buildItem({ + id: 'leave', + label: 'Leave Group', + type: 'destructive', + }); + const deleteItem = buildItem({ + id: 'deleteChannel', + label: 'Delete Group', + type: 'destructive', + }); + + it('renders one list item per action item', () => { + useActionItemsSpy.mockReturnValue([muteItem, leaveItem, deleteItem]); + renderSection(); + expect(probeCalls).toHaveLength(3); + expect(probeCalls.map((p) => p.label)).toEqual(['Mute Group', 'Leave Group', 'Delete Group']); + }); + + it('builds testIDs from the item id', () => { + useActionItemsSpy.mockReturnValue([muteItem, leaveItem, deleteItem]); + renderSection(); + expect(screen.getByTestId('channel-details-action-mute')).toBeTruthy(); + expect(screen.getByTestId('channel-details-action-leave')).toBeTruthy(); + expect(screen.getByTestId('channel-details-action-deleteChannel')).toBeTruthy(); + }); + + it('forwards the icon, label, and onPress to ChannelDetailsListItem', () => { + useActionItemsSpy.mockReturnValue([muteItem]); + renderSection(); + const [item] = probeCalls; + expect(item.Icon).toBe(muteItem.Icon); + expect(item.label).toBe('Mute Group'); + expect(typeof item.onPress).toBe('function'); + }); + + it('passes destructive=true only for items with type="destructive"', () => { + useActionItemsSpy.mockReturnValue([muteItem, leaveItem, deleteItem]); + renderSection(); + const byId = Object.fromEntries(probeCalls.map((p) => [p.testID, p.destructive])); + expect(byId['channel-details-action-mute']).toBe(false); + expect(byId['channel-details-action-leave']).toBe(true); + expect(byId['channel-details-action-deleteChannel']).toBe(true); + }); + + it('invokes the original action when the list item is pressed', () => { + const action = jest.fn(); + useActionItemsSpy.mockReturnValue([buildItem({ action, id: 'mute', label: 'Mute Group' })]); + renderSection(); + fireEvent.press(screen.getByTestId('channel-details-action-mute')); + expect(action).toHaveBeenCalledTimes(1); + }); + }); + + describe('accessibility hints', () => { + const leaveItem = buildItem({ id: 'leave', label: 'Leave Group', type: 'destructive' }); + const deleteItem = buildItem({ + id: 'deleteChannel', + label: 'Delete Group', + type: 'destructive', + }); + const muteItem = buildItem({ id: 'mute', label: 'Mute Group' }); + + it('omits hints when AccessibilityContext is disabled (default)', () => { + useActionItemsSpy.mockReturnValue([muteItem, leaveItem, deleteItem]); + renderSection({ a11yEnabled: false }); + for (const item of probeCalls) { + expect(item.accessibilityHint).toBeUndefined(); + } + }); + + it('applies the group-specific leave/delete hints when accessibility is enabled and chat is a group', () => { + useIsDirectChatSpy.mockReturnValue(false); + useActionItemsSpy.mockReturnValue([muteItem, leaveItem, deleteItem]); + renderSection({ a11yEnabled: true }); + const byId = Object.fromEntries(probeCalls.map((p) => [p.testID, p.accessibilityHint])); + expect(byId['channel-details-action-mute']).toBeUndefined(); + expect(byId['channel-details-action-leave']).toBe('a11y/Removes you from this group'); + expect(byId['channel-details-action-deleteChannel']).toBe( + 'a11y/Deletes this group permanently', + ); + }); + + it('applies the direct-chat-specific hints when accessibility is enabled and chat is direct', () => { + useIsDirectChatSpy.mockReturnValue(true); + useActionItemsSpy.mockReturnValue([leaveItem, deleteItem]); + renderSection({ a11yEnabled: true }); + const byId = Object.fromEntries(probeCalls.map((p) => [p.testID, p.accessibilityHint])); + expect(byId['channel-details-action-leave']).toBe('a11y/Removes you from this chat'); + expect(byId['channel-details-action-deleteChannel']).toBe( + 'a11y/Deletes this chat permanently', + ); + }); + }); + + describe('ChannelDetailsListItem override', () => { + it('uses the override passed via WithComponents instead of the default', () => { + useActionItemsSpy.mockReturnValue([buildItem({ id: 'mute', label: 'Mute Group' })]); + renderSection(); + // Probe is our injected override — its presence proves the override path is used. + expect(probeCalls).toHaveLength(1); + }); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsListItem.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsListItem.test.tsx new file mode 100644 index 0000000000..281a5d1ed0 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsListItem.test.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { ThemeProvider } from '../../../contexts'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import type { IconProps } from '../../../icons/utils/base'; +import { ChannelDetailsListItem } from '../components/ChannelDetailsListItem'; + +const TestIcon = jest.fn(() => null); + +const renderItem = (props: Partial> = {}) => + render( + + + , + ); + +describe('ChannelDetailsListItem', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders the provided label', () => { + renderItem({ label: 'Mute Group' }); + expect(screen.getByText('Mute Group')).toBeTruthy(); + }); + + it('renders the icon', () => { + renderItem(); + expect(TestIcon).toHaveBeenCalled(); + }); + + it('renders the trailing slot when provided', () => { + renderItem({ trailing: 5 }); + expect(screen.getByTestId('trailing', { includeHiddenElements: true })).toBeTruthy(); + }); + + it('omits the trailing slot when not provided', () => { + renderItem({ testID: 'item' }); + expect(screen.queryByTestId('trailing', { includeHiddenElements: true })).toBeNull(); + }); + }); + + describe('interaction surface', () => { + it('renders as a non-interactive row when onPress is not provided', () => { + renderItem({ testID: 'item' }); + const row = screen.getByTestId('item'); + expect(row.props.accessibilityRole).toBeUndefined(); + expect(row.props.accessibilityLabel).toBeUndefined(); + }); + + it('renders as a button with the label as accessibilityLabel when onPress is provided', () => { + renderItem({ onPress: jest.fn(), testID: 'item' }); + const row = screen.getByTestId('item'); + expect(row.props.accessibilityRole).toBe('button'); + expect(row.props.accessibilityLabel).toBe('Pinned Messages'); + }); + + it('forwards accessibilityHint when provided', () => { + renderItem({ + accessibilityHint: 'Removes you from this group', + onPress: jest.fn(), + testID: 'item', + }); + expect(screen.getByTestId('item').props.accessibilityHint).toBe( + 'Removes you from this group', + ); + }); + + it('invokes onPress when the row is pressed', () => { + const onPress = jest.fn(); + renderItem({ onPress, testID: 'item' }); + fireEvent.press(screen.getByTestId('item')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('does not throw when pressed without an onPress (read-only row)', () => { + renderItem({ testID: 'item' }); + expect(() => fireEvent.press(screen.getByTestId('item'))).not.toThrow(); + }); + }); + + describe('destructive variant', () => { + const lastIconProps = () => TestIcon.mock.calls[TestIcon.mock.calls.length - 1][0]; + const labelColor = () => { + const styles = screen.getByText('Pinned Messages').props.style as Array< + { color?: string } | undefined + >; + return styles.find((s) => s?.color)?.color; + }; + + it('uses the same color for fill and stroke', () => { + renderItem(); + const icon = lastIconProps(); + expect(icon.fill).toBe(icon.stroke); + }); + + it('paints the icon and label differently when destructive vs standard', () => { + const { rerender } = renderItem({ destructive: false }); + const standardIcon = lastIconProps().fill; + const standardLabelColor = labelColor(); + + TestIcon.mockClear(); + rerender( + + + , + ); + const destructiveIcon = lastIconProps().fill; + const destructiveLabelColor = labelColor(); + + expect(destructiveIcon).not.toBe(standardIcon); + expect(destructiveLabelColor).not.toBe(standardLabelColor); + expect(destructiveIcon).toBe(destructiveLabelColor); + }); + }); + + describe('accessibility', () => { + it('hides the trailing slot from assistive tech (label carries the announcement)', () => { + renderItem({ testID: 'item', trailing: 5 }); + // Hidden by default (a11y queries skip it)… + expect(screen.queryByTestId('trailing')).toBeNull(); + // …but still present in the tree. + expect(screen.queryByTestId('trailing', { includeHiddenElements: true })).toBeTruthy(); + }); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberList.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberList.test.tsx new file mode 100644 index 0000000000..574e98c8de --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberList.test.tsx @@ -0,0 +1,143 @@ +import React, { type ComponentProps } from 'react'; +import { Text } from 'react-native'; + +import { render } from '@testing-library/react-native'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; + +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 { generateMember } from '../../../mock-builders/generator/member'; +import { generateUser } from '../../../mock-builders/generator/user'; +import { StreamBottomSheetModalFlatList } from '../../UIComponents/StreamBottomSheetModalFlatList'; +import { ChannelDetailsMemberList } from '../components/ChannelDetailsMemberList'; +import type { ChannelDetailsMemberListItemProps } from '../components/ChannelDetailsMemberListItem'; + +type FlatListProps = ComponentProps>; + +const mockStreamBottomSheetModalFlatList = jest.fn( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_props: FlatListProps) => null, +); + +jest.mock('../../UIComponents/StreamBottomSheetModalFlatList', () => ({ + StreamBottomSheetModalFlatList: (...args: [FlatListProps]) => + mockStreamBottomSheetModalFlatList(...args), +})); + +const buildChannel = (members: ChannelMemberResponse[]): Channel => + ({ + cid: 'messaging:test', + data: {}, + on: () => ({ unsubscribe: () => undefined }), + state: { + members: Object.fromEntries( + members.map((m) => [m.user?.id ?? m.user_id ?? '', m]).filter(([k]) => Boolean(k)), + ), + }, + }) as unknown as Channel; + +type Probe = ChannelDetailsMemberListItemProps; + +const probeCalls: Probe[] = []; +const MemberListItemProbe = (props: Probe) => { + probeCalls.push(props); + return {props.member.user?.name}; +}; + +const renderList = ({ channel, currentUserId }: { channel: Channel; currentUserId?: string }) => + render( + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + + + + + + + + , + ); + +const renderItemsFromMock = (members: ChannelMemberResponse[]) => { + mockStreamBottomSheetModalFlatList.mockClear(); + probeCalls.length = 0; + + return members; +}; + +describe('ChannelDetailsMemberList', () => { + beforeEach(() => { + mockStreamBottomSheetModalFlatList.mockClear(); + probeCalls.length = 0; + }); + + it('forwards every channel member into the bottom sheet flat list', () => { + const alice = generateMember({ user: generateUser({ id: 'alice', name: 'Alice' }) }); + const bob = generateMember({ user: generateUser({ id: 'bob', name: 'Bob' }) }); + const channel = buildChannel([alice, bob]); + + renderList({ channel }); + + expect(mockStreamBottomSheetModalFlatList).toHaveBeenCalled(); + const props = mockStreamBottomSheetModalFlatList.mock.calls[0]?.[0]; + expect(props.data).toHaveLength(2); + expect(props.data?.map((m) => m.user?.id)).toEqual(['alice', 'bob']); + expect(typeof props.renderItem).toBe('function'); + expect(typeof props.keyExtractor).toBe('function'); + }); + + it('uses a stable keyExtractor based on user.id', () => { + const alice = generateMember({ user: generateUser({ id: 'alice' }) }); + const channel = buildChannel([alice]); + + renderList({ channel }); + + const { keyExtractor } = mockStreamBottomSheetModalFlatList.mock.calls[0]?.[0] ?? {}; + expect(keyExtractor?.(alice, 0)).toBe('alice'); + }); + + it('renders the resolved item component with the isCurrentUser flag', () => { + const alice = generateMember({ user: generateUser({ id: 'alice', name: 'Alice' }) }); + const bob = generateMember({ user: generateUser({ id: 'bob', name: 'Bob' }) }); + renderItemsFromMock([alice, bob]); + const channel = buildChannel([alice, bob]); + + renderList({ channel, currentUserId: 'alice' }); + + const { data, renderItem } = mockStreamBottomSheetModalFlatList.mock.calls[0]?.[0] ?? {}; + expect(data).toHaveLength(2); + + data?.forEach((member, index) => { + render( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (renderItem as any)({ index, item: member, separators: {} as never }), + ); + }); + + expect(probeCalls).toHaveLength(2); + const byId = Object.fromEntries(probeCalls.map((p) => [p.member.user?.id, p])); + expect(byId.alice.isCurrentUser).toBe(true); + expect(byId.bob.isCurrentUser).toBe(false); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberListItem.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberListItem.test.tsx new file mode 100644 index 0000000000..4116ee623b --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberListItem.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react-native'; +import Dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import type { ChannelMemberResponse } from 'stream-chat'; + +import { ThemeProvider } from '../../../contexts'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import { ChannelDetailsMemberListItem } from '../components/ChannelDetailsMemberListItem'; + +Dayjs.extend(relativeTime); + +const memberFor = (overrides: Partial> = {}) => + ({ + user: { + id: 'alice', + name: 'Alice', + online: false, + ...overrides, + }, + }) as unknown as ChannelMemberResponse; + +const renderRow = (props: React.ComponentProps) => + render( + + ) => { + if (options && 'relativeTime' in options) { + return key.replace('{{relativeTime}}', String(options.relativeTime)); + } + return key; + }) as never, + tDateTimeParser: (input) => Dayjs(input), + userLanguage: 'en', + }} + > + + + , + ); + +describe('ChannelDetailsMemberListItem accessibility', () => { + it('composes name and offline status into the accessible label', () => { + renderRow({ member: memberFor() }); + expect(screen.getByLabelText('Alice, Offline')).toBeTruthy(); + }); + + it('includes the online status in the accessible label when the member is online', () => { + renderRow({ member: memberFor({ online: true }) }); + expect(screen.getByLabelText('Alice, Online')).toBeTruthy(); + }); + + it('uses "You" when the row represents the current user', () => { + renderRow({ isCurrentUser: true, member: memberFor() }); + expect(screen.getByLabelText('You, Offline')).toBeTruthy(); + }); +}); + +describe('ChannelDetailsMemberListItem activity status', () => { + it('shows "Online" for an online member', () => { + renderRow({ member: memberFor({ online: true }) }); + expect(screen.getByText('Online')).toBeTruthy(); + }); + + it('shows "Offline" for an offline member with no last_active', () => { + renderRow({ member: memberFor({ online: false }) }); + expect(screen.getByText('Offline')).toBeTruthy(); + }); + + it('shows a "Last seen ..." string for an offline member with last_active', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-05-13T12:00:00Z')); + const tenMinutesAgo = new Date('2026-05-13T11:50:00Z').toISOString(); + + renderRow({ member: memberFor({ last_active: tenMinutesAgo, online: false }) }); + + expect(screen.getByText(/^Last seen /)).toBeTruthy(); + jest.useRealTimers(); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberSection.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberSection.test.tsx new file mode 100644 index 0000000000..46015f238b --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberSection.test.tsx @@ -0,0 +1,226 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +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, + OwnCapabilitiesProvider, + 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 { ChannelDetailsMemberSection } from '../components/ChannelDetailsMemberSection'; +import * as useChannelDetailsMembersPreviewModule from '../hooks/useChannelDetailsMembersPreview'; + +const MemberListProbe = () => full-member-list; + +const buildChannel = (members: ChannelMemberResponse[], memberCount?: number): 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)), + ), + }, + }) as unknown as Channel; + +const buildCapabilities = ( + overrides?: Partial, +): OwnCapabilitiesContextValue => + Object.keys(allOwnCapabilities).reduce( + (acc, capability) => ({ + ...acc, + [capability]: overrides?.[capability as OwnCapability] ?? false, + }), + {} as OwnCapabilitiesContextValue, + ); + +const renderSection = ({ + capabilities, + channel, + onAddMembersPress, + onViewAllMembersPress, +}: { + channel: Channel; + capabilities?: Partial; + onAddMembersPress?: () => void; + onViewAllMembersPress?: () => void; +}) => + 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('ChannelDetailsMemberSection', () => { + let previewSpy: jest.SpyInstance; + + beforeEach(() => { + previewSpy = jest.spyOn( + useChannelDetailsMembersPreviewModule, + 'useChannelDetailsMembersPreview', + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('hides the "View all" affordance when there are no extra members', () => { + previewSpy.mockReturnValue({ hasMore: false, total: 3, visible: makeMembers(3) }); + const channel = buildChannel(makeMembers(3), 3); + + renderSection({ channel }); + + expect(screen.queryByLabelText('View all')).toBeNull(); + }); + + it('shows the "View all" affordance when there are more members than the preview shows', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + + renderSection({ channel }); + + expect(screen.getByLabelText('View all')).toBeTruthy(); + }); + + it('opens the bottom-sheet member list when "View all" is pressed and no override is provided', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + + renderSection({ channel }); + + expect(screen.queryByTestId('member-list-probe')).toBeNull(); + fireEvent.press(screen.getByLabelText('View all')); + expect(screen.getByTestId('member-list-probe')).toBeTruthy(); + }); + + it('calls onViewAllMembersPress instead of opening the modal when provided', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + const onViewAllMembersPress = jest.fn(); + + renderSection({ channel, onViewAllMembersPress }); + + fireEvent.press(screen.getByLabelText('View all')); + + expect(onViewAllMembersPress).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('member-list-probe')).toBeNull(); + }); + + it('closes the bottom-sheet member list when the close button is pressed', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + + renderSection({ channel }); + + fireEvent.press(screen.getByLabelText('View all')); + expect(screen.getByTestId('member-list-probe')).toBeTruthy(); + + fireEvent.press(screen.getByLabelText('a11y/Close')); + + expect(screen.queryByTestId('member-list-probe')).toBeNull(); + }); + + it('does not render any add-members affordance when the user lacks update-channel-members capability', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + + renderSection({ channel }); + + expect(screen.queryByTestId('channel-details-member-section-add-button')).toBeNull(); + + fireEvent.press(screen.getByLabelText('View all')); + + expect(screen.queryByTestId('channel-details-member-list-add-button')).toBeNull(); + }); + + it('renders the preview add button and invokes onAddMembersPress 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, + }); + + const previewAddButton = screen.getByTestId('channel-details-member-section-add-button'); + fireEvent.press(previewAddButton); + + expect(onAddMembersPress).toHaveBeenCalledTimes(1); + }); + + it('renders the modal add button and invokes onAddMembersPress when the user has the capability', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + const onAddMembersPress = jest.fn(); + + renderSection({ + capabilities: { updateChannelMembers: true }, + channel, + onAddMembersPress, + }); + + fireEvent.press(screen.getByLabelText('View all')); + fireEvent.press(screen.getByTestId('channel-details-member-list-add-button')); + + // Preview-Add (first press inside renderSection: not pressed) is not counted; only the + // modal-Add press fires the callback. + expect(onAddMembersPress).toHaveBeenCalledTimes(1); + }); + + it('renders the modal add-members button as a no-op when onAddMembersPress is omitted', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + + renderSection({ + capabilities: { updateChannelMembers: true }, + channel, + }); + + fireEvent.press(screen.getByLabelText('View all')); + + const addButton = screen.getByTestId('channel-details-member-list-add-button'); + expect(addButton).toBeTruthy(); + expect(() => fireEvent.press(addButton)).not.toThrow(); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsProfile.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsProfile.test.tsx new file mode 100644 index 0000000000..aa444bb9a0 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsProfile.test.tsx @@ -0,0 +1,186 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react-native'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; + +import { ChannelDetailsContextProvider } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { ChatProvider } from '../../../contexts/chatContext/ChatContext'; +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import * as useChannelMuteActiveModule from '../../../hooks/useChannelMuteActive'; +import * as useIsDirectChatModule from '../../../hooks/useIsDirectChat'; +import * as useChannelMembersStateModule from '../../ChannelList/hooks/useChannelMembersState'; +import * as useChannelPreviewDisplayNameModule from '../../ChannelPreview/hooks/useChannelPreviewDisplayName'; +import { ChannelDetailsProfile } from '../components/ChannelDetailsProfile'; +import * as useChannelDetailsMemberStatusTextModule from '../hooks/useChannelDetailsMemberStatusText'; + +const channelAvatarCalls: Array<{ size?: string; showBorder?: boolean }> = []; +jest.mock('../../ui/Avatar/ChannelAvatar', () => { + const RN = jest.requireActual('react-native'); + const ReactActual = jest.requireActual('react'); + return { + ChannelAvatar: (props: { size?: string; showBorder?: boolean }) => { + channelAvatarCalls.push({ showBorder: props.showBorder, size: props.size }); + return ReactActual.createElement(RN.View, { testID: 'channel-avatar' }); + }, + }; +}); + +const OWN_USER_ID = 'own-user'; +const OTHER_USER_ID = 'other-user'; + +const buildMember = (id: string, online = false): ChannelMemberResponse => + ({ + user: { id, online }, + user_id: id, + }) as unknown as ChannelMemberResponse; + +const buildChannel = () => + ({ + cid: 'messaging:test', + getClient: () => ({ userID: OWN_USER_ID }), + data: {}, + }) as unknown as Channel; + +const renderProfile = ({ channel = buildChannel() }: { channel?: Channel } = {}) => + render( + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + + + + + + , + ); + +describe('ChannelDetailsProfile', () => { + let useIsDirectChatSpy: jest.SpyInstance; + let useChannelMembersStateSpy: jest.SpyInstance; + let useChannelPreviewDisplayNameSpy: jest.SpyInstance; + let useChannelDetailsMemberStatusTextSpy: jest.SpyInstance; + let useChannelMuteActiveSpy: jest.SpyInstance; + + beforeEach(() => { + channelAvatarCalls.length = 0; + useIsDirectChatSpy = jest + .spyOn(useIsDirectChatModule, 'useIsDirectChat') + .mockReturnValue(false); + useChannelMembersStateSpy = jest + .spyOn(useChannelMembersStateModule, 'useChannelMembersState') + .mockReturnValue({}); + useChannelPreviewDisplayNameSpy = jest + .spyOn(useChannelPreviewDisplayNameModule, 'useChannelPreviewDisplayName') + .mockReturnValue('Display Name'); + useChannelDetailsMemberStatusTextSpy = jest + .spyOn(useChannelDetailsMemberStatusTextModule, 'useChannelDetailsMemberStatusText') + .mockReturnValue('12 members, 3 online'); + useChannelMuteActiveSpy = jest + .spyOn(useChannelMuteActiveModule, 'useChannelMuteActive') + .mockReturnValue(false); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('default rendering', () => { + it('renders the channel avatar with size="2xl" and no border', () => { + renderProfile(); + expect(screen.getByTestId('channel-avatar')).toBeTruthy(); + const last = channelAvatarCalls[channelAvatarCalls.length - 1]; + expect(last.size).toBe('2xl'); + expect(last.showBorder).toBe(false); + }); + + it('renders the display name as the title', () => { + renderProfile(); + expect(screen.getByText('Display Name')).toBeTruthy(); + }); + + it('marks the title with accessibilityRole="header"', () => { + renderProfile(); + const title = screen.getByText('Display Name'); + expect(title.props.accessibilityRole).toBe('header'); + }); + + it('renders an empty title when the display name is missing', () => { + useChannelPreviewDisplayNameSpy.mockReturnValue(undefined); + const { toJSON } = renderProfile(); + // No crash, and a Text node renders (empty string) + expect(toJSON()).toBeTruthy(); + }); + }); + + describe('group chats', () => { + beforeEach(() => { + useIsDirectChatSpy.mockReturnValue(false); + }); + + it('renders the group status text as the subtitle', () => { + renderProfile(); + expect(screen.getByText('12 members, 3 online')).toBeTruthy(); + }); + + it('does not render a subtitle when the group status text is empty', () => { + useChannelDetailsMemberStatusTextSpy.mockReturnValue(''); + renderProfile(); + expect(screen.queryByText('12 members, 3 online')).toBeNull(); + }); + }); + + describe('direct chats', () => { + beforeEach(() => { + useIsDirectChatSpy.mockReturnValue(true); + }); + + it('renders "Online" when the other member is online', () => { + useChannelMembersStateSpy.mockReturnValue({ + [OWN_USER_ID]: buildMember(OWN_USER_ID, true), + [OTHER_USER_ID]: buildMember(OTHER_USER_ID, true), + }); + renderProfile(); + expect(screen.getByText('Online')).toBeTruthy(); + }); + + it('does not render a subtitle when the other member is offline', () => { + useChannelMembersStateSpy.mockReturnValue({ + [OWN_USER_ID]: buildMember(OWN_USER_ID, true), + [OTHER_USER_ID]: buildMember(OTHER_USER_ID, false), + }); + renderProfile(); + expect(screen.queryByText('Online')).toBeNull(); + }); + + it('ignores the group status text in direct chats', () => { + useChannelMembersStateSpy.mockReturnValue({ + [OWN_USER_ID]: buildMember(OWN_USER_ID, true), + [OTHER_USER_ID]: buildMember(OTHER_USER_ID, false), + }); + renderProfile(); + expect(screen.queryByText('12 members, 3 online')).toBeNull(); + }); + }); + + describe('muted indicator', () => { + it('renders the muted indicator when useChannelMuteActive returns true', () => { + useChannelMuteActiveSpy.mockReturnValue(true); + renderProfile(); + expect(screen.getByTestId('channel-details-profile-muted-indicator')).toBeTruthy(); + }); + + it('does not render the muted indicator when useChannelMuteActive returns false', () => { + useChannelMuteActiveSpy.mockReturnValue(false); + renderProfile(); + expect(screen.queryByTestId('channel-details-profile-muted-indicator')).toBeNull(); + }); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsScreen.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsScreen.test.tsx new file mode 100644 index 0000000000..ebbb5ea107 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsScreen.test.tsx @@ -0,0 +1,197 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { render, screen } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; +import type { OwnCapabilitiesContextValue } from '../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; +import { useOwnCapabilitiesContext } from '../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import * as useIsDirectChatModule from '../../../hooks/useIsDirectChat'; +import { ChannelDetailsScreen } from '../ChannelDetailsScreen'; + +const HeaderProbe = () => HEADER; +const ProfileProbe = () => PROFILE; +const NavigationProbe = () => NAVIGATION; +const MemberProbe = () => MEMBER; +const ActionsProbe = () => ACTIONS; + +const SECTION_OVERRIDES = { + ChannelDetailsActionsSection: ActionsProbe, + ChannelDetailsMemberSection: MemberProbe, + ChannelDetailsNavigationSection: NavigationProbe, + ChannelDetailsProfile: ProfileProbe, + ChannelDetailsScreenHeader: HeaderProbe, +}; + +const channel = { + cid: 'messaging:test', + id: 'test', + on: jest.fn(() => ({ unsubscribe: jest.fn() })), +} as unknown as Channel; + +const renderContent = () => + render( + + + + + , + ); + +describe('ChannelDetailsScreenContent', () => { + let useIsDirectChatSpy: jest.SpyInstance; + + beforeEach(() => { + useIsDirectChatSpy = jest + .spyOn(useIsDirectChatModule, 'useIsDirectChat') + .mockReturnValue(false); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('section composition', () => { + it('renders header, profile, navigation, and actions sections', () => { + renderContent(); + expect(screen.getByTestId('probe-header')).toBeTruthy(); + expect(screen.getByTestId('probe-profile')).toBeTruthy(); + expect(screen.getByTestId('probe-navigation')).toBeTruthy(); + expect(screen.getByTestId('probe-actions')).toBeTruthy(); + }); + + it('renders the member section for group chats', () => { + useIsDirectChatSpy.mockReturnValue(false); + renderContent(); + expect(screen.getByTestId('probe-member')).toBeTruthy(); + }); + + it('hides the member section for direct chats', () => { + useIsDirectChatSpy.mockReturnValue(true); + renderContent(); + expect(screen.queryByTestId('probe-member')).toBeNull(); + }); + }); +}); + +describe('ChannelDetailsScreen', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('context provisioning', () => { + it('exposes channel and callbacks via ChannelDetailsContext', () => { + const onChannelDismiss = jest.fn(); + const onBack = jest.fn(); + let captured: ReturnType | undefined; + const ContextProbe = () => { + captured = useChannelDetailsContext(); + return null; + }; + + render( + + + + + , + ); + + expect(captured).toBeDefined(); + expect(captured?.channel).toBe(channel); + expect(captured?.onChannelDismiss).toBe(onChannelDismiss); + expect(captured?.onBack).toBe(onBack); + }); + + it('exposes own capabilities derived from the channel via OwnCapabilitiesContext', () => { + const unsubscribe = jest.fn(); + const channelWithCapabilities = { + cid: 'messaging:test', + id: 'test', + data: { own_capabilities: ['send-message', 'delete-own-message'] }, + on: jest.fn(() => ({ unsubscribe })), + } as unknown as Channel; + + let captured: OwnCapabilitiesContextValue | undefined; + const CapabilitiesProbe = () => { + captured = useOwnCapabilitiesContext(); + return null; + }; + + render( + + + + + , + ); + + expect(captured).toBeDefined(); + expect(captured?.sendMessage).toBe(true); + expect(captured?.deleteOwnMessage).toBe(true); + expect(captured?.banChannelMembers).toBe(false); + expect(channelWithCapabilities.on).toHaveBeenCalledWith( + 'capabilities.changed', + expect.any(Function), + ); + }); + }); + + describe('ChannelDetailsScreenContent override', () => { + it('renders the override instead of the default content', () => { + const Override = () => CUSTOM; + render( + + + + + , + ); + + expect(screen.getByTestId('custom-content')).toBeTruthy(); + // The default content's section probes should not render. + expect(screen.queryByTestId('probe-header')).toBeNull(); + expect(screen.queryByTestId('probe-profile')).toBeNull(); + }); + }); + + describe('default content path', () => { + it('falls back to ChannelDetailsScreenContent when no override is supplied', () => { + jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(false); + // Note: re-export the default Content via the override map so we can prove it + // wasn't swapped out — the section probes from SECTION_OVERRIDES should appear. + render( + + + + + , + ); + expect(screen.getByTestId('probe-header')).toBeTruthy(); + expect(screen.getByTestId('probe-actions')).toBeTruthy(); + }); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/useChannelDetailsActionItems.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/useChannelDetailsActionItems.test.tsx new file mode 100644 index 0000000000..e91a0eb993 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/useChannelDetailsActionItems.test.tsx @@ -0,0 +1,220 @@ +import { renderHook } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import * as channelDetailsContextModule from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import type { ChannelActionItem } from '../../../hooks/useChannelActionItems'; +import * as useChannelActionItemsModule from '../../../hooks/useChannelActionItems'; +import { useChannelDetailsActionItems } from '../hooks/useChannelDetailsActionItems'; + +type Customizer = NonNullable< + Parameters[0]['getChannelActionItems'] +>; + +const NoopIcon = () => null; + +const buildItem = (overrides: Partial): ChannelActionItem => ({ + action: jest.fn(), + Icon: NoopIcon, + id: 'mute', + label: 'Mute', + placement: 'sheet', + type: 'standard', + ...overrides, +}); + +const channel = { id: 'channel-id' } as unknown as Channel; + +const mockContext = ( + overrides: Partial = {}, +) => { + const value: channelDetailsContextModule.ChannelDetailsContextValue = { + channel, + onChannelDismiss: jest.fn(), + ...overrides, + }; + jest.spyOn(channelDetailsContextModule, 'useChannelDetailsContext').mockReturnValue(value); + return value; +}; + +describe('useChannelDetailsActionItems', () => { + let capturedCustomizer: Customizer | undefined; + let useChannelActionItemsSpy: jest.SpyInstance; + const returnedItems: ChannelActionItem[] = [buildItem({ id: 'mute' })]; + + beforeEach(() => { + capturedCustomizer = undefined; + useChannelActionItemsSpy = jest + .spyOn(useChannelActionItemsModule, 'useChannelActionItems') + .mockImplementation((params) => { + capturedCustomizer = params.getChannelActionItems; + return returnedItems; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('calls useChannelActionItems with the channel from context and a customizer', () => { + mockContext(); + renderHook(() => useChannelDetailsActionItems()); + + expect(useChannelActionItemsSpy).toHaveBeenCalledTimes(1); + expect(useChannelActionItemsSpy).toHaveBeenCalledWith({ + channel, + getChannelActionItems: expect.any(Function), + }); + }); + + it('returns whatever useChannelActionItems returns', () => { + mockContext(); + const { result } = renderHook(() => useChannelDetailsActionItems()); + + expect(result.current).toBe(returnedItems); + }); + + it('passes unrelated items through unchanged', () => { + mockContext(); + renderHook(() => useChannelDetailsActionItems()); + + const muteItem = buildItem({ id: 'mute' }); + const archiveItem = buildItem({ id: 'archive' }); + const result = capturedCustomizer!({ + context: { channel } as never, + defaultItems: [muteItem, archiveItem], + }); + + expect(result).toHaveLength(2); + expect(result[0]).toBe(muteItem); + expect(result[1]).toBe(archiveItem); + }); + + it('wraps leave action to call onChannelDismiss after the original action resolves', async () => { + const { onChannelDismiss } = mockContext(); + renderHook(() => useChannelDetailsActionItems()); + + const callOrder: string[] = []; + let resolveLeave: (() => void) | undefined; + const originalLeave = jest.fn( + () => + new Promise((resolve) => { + callOrder.push('leave-start'); + resolveLeave = () => { + callOrder.push('leave-resolved'); + resolve(); + }; + }), + ); + (onChannelDismiss as jest.Mock).mockImplementation(() => { + callOrder.push('onChannelDismiss'); + }); + + const leaveItem = buildItem({ + action: originalLeave, + id: 'leave', + label: 'Leave Group', + placement: 'sheet', + type: 'destructive', + }); + + const [wrapped] = capturedCustomizer!({ + context: { channel } as never, + defaultItems: [leaveItem], + }); + + expect(wrapped).not.toBe(leaveItem); + expect(wrapped.id).toBe('leave'); + expect(wrapped.label).toBe('Leave Group'); + expect(wrapped.type).toBe('destructive'); + + const pending = wrapped.action(); + expect(originalLeave).toHaveBeenCalledTimes(1); + expect(onChannelDismiss).not.toHaveBeenCalled(); + + resolveLeave!(); + await pending; + + expect(onChannelDismiss).toHaveBeenCalledTimes(1); + expect(onChannelDismiss).toHaveBeenCalledWith(); + expect(callOrder).toEqual(['leave-start', 'leave-resolved', 'onChannelDismiss']); + }); + + it('wraps deleteChannel action to call onChannelDismiss after the original action resolves', async () => { + const { onChannelDismiss } = mockContext(); + renderHook(() => useChannelDetailsActionItems()); + + const callOrder: string[] = []; + let resolveDelete: (() => void) | undefined; + const originalDelete = jest.fn( + () => + new Promise((resolve) => { + callOrder.push('delete-start'); + resolveDelete = () => { + callOrder.push('delete-resolved'); + resolve(); + }; + }), + ); + (onChannelDismiss as jest.Mock).mockImplementation(() => { + callOrder.push('onChannelDismiss'); + }); + + const deleteItem = buildItem({ + action: originalDelete, + id: 'deleteChannel', + label: 'Delete Group', + placement: 'sheet', + type: 'destructive', + }); + + const [wrapped] = capturedCustomizer!({ + context: { channel } as never, + defaultItems: [deleteItem], + }); + + expect(wrapped).not.toBe(deleteItem); + expect(wrapped.id).toBe('deleteChannel'); + expect(wrapped.label).toBe('Delete Group'); + expect(wrapped.type).toBe('destructive'); + + const pending = wrapped.action(); + expect(originalDelete).toHaveBeenCalledTimes(1); + expect(onChannelDismiss).not.toHaveBeenCalled(); + + resolveDelete!(); + await pending; + + expect(onChannelDismiss).toHaveBeenCalledTimes(1); + expect(onChannelDismiss).toHaveBeenCalledWith(); + expect(callOrder).toEqual(['delete-start', 'delete-resolved', 'onChannelDismiss']); + }); + + it('does not throw when onChannelDismiss is undefined on the leave path', async () => { + mockContext({ onChannelDismiss: undefined }); + renderHook(() => useChannelDetailsActionItems()); + + const originalLeave = jest.fn().mockResolvedValue(undefined); + const [wrapped] = capturedCustomizer!({ + context: { channel } as never, + defaultItems: [buildItem({ action: originalLeave, id: 'leave' })], + }); + + await expect(wrapped.action()).resolves.toBeUndefined(); + expect(originalLeave).toHaveBeenCalledTimes(1); + }); + + it('does not throw when onChannelDismiss is undefined on the deleteChannel path', async () => { + mockContext({ onChannelDismiss: undefined }); + renderHook(() => useChannelDetailsActionItems()); + + const originalDelete = jest.fn().mockResolvedValue(undefined); + const [wrapped] = capturedCustomizer!({ + context: { channel } as never, + defaultItems: [buildItem({ action: originalDelete, id: 'deleteChannel' })], + }); + + await expect(wrapped.action()).resolves.toBeUndefined(); + expect(originalDelete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/useUserActivityStatus.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/useUserActivityStatus.test.tsx new file mode 100644 index 0000000000..d3f28021b7 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/useUserActivityStatus.test.tsx @@ -0,0 +1,75 @@ +import React, { type PropsWithChildren } from 'react'; + +import { renderHook } from '@testing-library/react-native'; +import Dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import type { UserResponse } from 'stream-chat'; + +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import { useUserActivityStatus } from '../hooks/useUserActivityStatus'; + +Dayjs.extend(relativeTime); + +const wrapper = ({ children }: PropsWithChildren) => ( + ) => { + if (options && 'relativeTime' in options) { + return `${key.replace('{{relativeTime}}', String(options.relativeTime))}`; + } + return key; + }) as never, + tDateTimeParser: (input) => Dayjs(input), + userLanguage: 'en', + }} + > + {children} + +); + +const userFor = (overrides: Partial = {}): UserResponse => + ({ id: 'u-1', ...overrides }) as UserResponse; + +describe('useUserActivityStatus', () => { + it('returns "Online" when the user is online', () => { + const { result } = renderHook(() => useUserActivityStatus(userFor({ online: true })), { + wrapper, + }); + expect(result.current).toBe('Online'); + }); + + it('returns "Offline" when the user is offline and has no last_active', () => { + const { result } = renderHook(() => useUserActivityStatus(userFor({ online: false })), { + wrapper, + }); + expect(result.current).toBe('Offline'); + }); + + it('returns "Offline" when no user is provided', () => { + const { result } = renderHook(() => useUserActivityStatus(undefined), { wrapper }); + expect(result.current).toBe('Offline'); + }); + + it('returns a relative "Last seen ..." string when offline with a valid last_active', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-05-13T12:00:00Z')); + const tenMinutesAgo = new Date('2026-05-13T11:50:00Z').toISOString(); + + const { result } = renderHook( + () => useUserActivityStatus(userFor({ last_active: tenMinutesAgo, online: false })), + { wrapper }, + ); + + expect(result.current).toMatch(/^Last seen /); + expect(result.current).toContain('minutes ago'); + + jest.useRealTimers(); + }); + + it('falls back to "Offline" when last_active is unparseable', () => { + const { result } = renderHook( + () => useUserActivityStatus(userFor({ last_active: 'not-a-date' as never, online: false })), + { wrapper }, + ); + expect(result.current).toBe('Offline'); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/components/ChannelDetailsActionsSection.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsActionsSection.tsx new file mode 100644 index 0000000000..0fd630f90a --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsActionsSection.tsx @@ -0,0 +1,75 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useA11yLabel } from '../../../a11y/hooks/useA11yLabel'; +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useIsDirectChat } from '../../../hooks/useIsDirectChat'; +import { primitives } from '../../../theme'; +import { useChannelDetailsActionItems } from '../hooks'; + +export const ChannelDetailsActionsSection = () => { + const { channel } = useChannelDetailsContext(); + const { + theme: { + channelDetailsScreen: { sectionCard: sectionCardOverride }, + semantics, + }, + } = useTheme(); + const { ChannelDetailsListItem } = useComponentsContext(); + const isDirect = useIsDirectChat(channel); + const leaveHint = useA11yLabel( + isDirect ? 'a11y/Removes you from this chat' : 'a11y/Removes you from this group', + ); + const deleteHint = useA11yLabel( + isDirect ? 'a11y/Deletes this chat permanently' : 'a11y/Deletes this group permanently', + ); + const styles = useStyles(); + + const items = useChannelDetailsActionItems(); + + if (items.length === 0) return null; + + return ( + + {items.map((item) => { + const testID = `channel-details-action-${item.id}`; + const accessibilityHint = + item.id === 'leave' ? leaveHint : item.id === 'deleteChannel' ? deleteHint : undefined; + + return ( + item.action()} + testID={testID} + /> + ); + })} + + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + sectionCard: { + borderRadius: primitives.radiusLg, + overflow: 'hidden', + paddingVertical: primitives.spacingXs, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetailsScreen/components/ChannelDetailsListItem.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsListItem.tsx new file mode 100644 index 0000000000..2e9404747e --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsListItem.tsx @@ -0,0 +1,141 @@ +import React, { useMemo } from 'react'; +import { I18nManager, Pressable, StyleSheet, Text, View } from 'react-native'; + +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import type { IconProps } from '../../../icons/utils/base'; +import { primitives } from '../../../theme'; + +export type ChannelDetailsListItemProps = { + Icon: React.ComponentType; + label: string; + accessibilityHint?: string; + destructive?: boolean; + onPress?: () => void; + testID?: string; + trailing?: React.ReactNode; +}; + +export const ChannelDetailsListItem = ({ + accessibilityHint, + Icon, + destructive = false, + label, + onPress, + testID, + trailing, +}: ChannelDetailsListItemProps) => { + const { + theme: { + channelDetailsScreen: { + listItem: { + container: containerOverride, + destructiveLabel: destructiveLabelOverride, + iconWrapper: iconWrapperOverride, + label: labelOverride, + }, + }, + semantics, + }, + } = useTheme(); + const styles = useStyles(); + const labelColor = destructive ? semantics.accentError : semantics.textPrimary; + const iconColor = destructive ? semantics.accentError : semantics.textPrimary; + + const content = ( + + + + + + {label} + + {trailing ? ( + + {trailing} + + ) : null} + + ); + + if (!onPress) { + return ( + + {content} + + ); + } + + return ( + [ + styles.row, + pressed ? { backgroundColor: semantics.backgroundUtilityPressed } : null, + ]} + testID={testID} + > + {content} + + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + contentContainer: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + gap: primitives.spacingSm, + minHeight: 48, + paddingHorizontal: primitives.spacingSm, + paddingVertical: primitives.spacingXs, + }, + iconWrapper: { + alignItems: 'center', + height: 20, + justifyContent: 'center', + width: 20, + }, + label: { + flex: 1, + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + row: { + alignItems: 'center', + flexDirection: 'row', + minHeight: 48, + paddingHorizontal: primitives.spacingXxs, + }, + trailing: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'flex-end', + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberList.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberList.tsx new file mode 100644 index 0000000000..1e62de8dcc --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberList.tsx @@ -0,0 +1,43 @@ +import React, { useCallback, useMemo } from 'react'; + +import type { ChannelMemberResponse } from 'stream-chat'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; +import { useChannelMembersState } from '../../ChannelList/hooks/useChannelMembersState'; +import { StreamBottomSheetModalFlatList } from '../../UIComponents/StreamBottomSheetModalFlatList'; + +const keyExtractor = (member: ChannelMemberResponse) => member.user?.id ?? member.user_id ?? ''; + +/** + * Renders the full list of channel members inside the channel-details bottom sheet. + * + * Reads `channel` from `ChannelDetailsContext` and the current user from `ChatContext`, + * then defers each row to `ChannelDetailsMemberListItem` (resolved via the components + * context so it remains overridable). Uses `StreamBottomSheetModalFlatList` so scroll + * is correctly handed off between the list and the surrounding bottom sheet. + */ +export const ChannelDetailsMemberList = () => { + const { channel } = useChannelDetailsContext(); + const { client } = useChatContext(); + const { ChannelDetailsMemberListItem } = useComponentsContext(); + const members = useChannelMembersState(channel); + + const data = useMemo(() => Object.values(members), [members]); + + const renderItem = useCallback( + ({ item }: { item: ChannelMemberResponse }) => ( + + ), + [ChannelDetailsMemberListItem, client.userID], + ); + + return ( + + ); +}; diff --git a/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberListItem.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberListItem.tsx new file mode 100644 index 0000000000..e47a84cabf --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberListItem.tsx @@ -0,0 +1,121 @@ +import React, { useMemo } from 'react'; +import { I18nManager, StyleSheet, Text, View } from 'react-native'; + +import type { ChannelMemberResponse } from 'stream-chat'; + +import { composeAccessibilityLabel } from '../../../a11y/a11yUtils'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { primitives } from '../../../theme'; +import { UserAvatar } from '../../ui/Avatar/UserAvatar'; +import { useUserActivityStatus } from '../hooks/useUserActivityStatus'; + +export type ChannelDetailsMemberListItemProps = { + member: ChannelMemberResponse; + isCurrentUser?: boolean; +}; + +const ChannelDetailsMemberListItemInner = ({ + isCurrentUser, + member, +}: ChannelDetailsMemberListItemProps) => { + const { t } = useTranslationContext(); + const { + theme: { + channelDetailsScreen: { + memberItem: { container: containerOverride, name: nameOverride, status: statusOverride }, + }, + semantics, + }, + } = useTheme(); + const styles = useStyles(); + const statusLine = useUserActivityStatus(member.user); + + const user = member.user; + if (!user) return null; + + const displayName = isCurrentUser ? t('You') : (user.name ?? user.id); + const accessibilityLabel = composeAccessibilityLabel(displayName, statusLine); + + return ( + + + + + {displayName} + + {statusLine ? ( + + {statusLine} + + ) : null} + + + ); +}; + +const areEqual = ( + prev: ChannelDetailsMemberListItemProps, + next: ChannelDetailsMemberListItemProps, +) => { + if (prev.isCurrentUser !== next.isCurrentUser) return false; + if (prev.member === next.member) return true; + const prevUser = prev.member.user; + const nextUser = next.member.user; + if (prevUser === nextUser) return prev.member.channel_role === next.member.channel_role; + if (!prevUser || !nextUser) return false; + return ( + prevUser.id === nextUser.id && + prevUser.name === nextUser.name && + prevUser.online === nextUser.online && + prevUser.image === nextUser.image && + prevUser.last_active === nextUser.last_active && + prev.member.channel_role === next.member.channel_role + ); +}; + +export const ChannelDetailsMemberListItem = React.memo(ChannelDetailsMemberListItemInner, areEqual); + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + body: { + flex: 1, + gap: 0, + minWidth: 0, + }, + container: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingSm, + minHeight: 48, + paddingHorizontal: primitives.spacingSm, + paddingVertical: primitives.spacingXs, + }, + name: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + status: { + fontSize: primitives.typographyFontSizeXs, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightTight, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberSection.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberSection.tsx new file mode 100644 index 0000000000..9422b1641c --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberSection.tsx @@ -0,0 +1,246 @@ +import React, { useMemo, useState } from 'react'; +import { I18nManager, Pressable, StyleSheet, Text, useWindowDimensions, View } from 'react-native'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; +import { useOwnCapabilitiesContext } from '../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useStableCallback } from '../../../hooks/useStableCallback'; +import { UserAdd } from '../../../icons/user-add'; +import { NewClose } from '../../../icons/xmark'; +import { primitives } from '../../../theme'; +import { Button } from '../../ui/Button/Button'; +import { BottomSheetModal } from '../../UIComponents/BottomSheetModal'; +import { useChannelDetailsMembersPreview } from '../hooks/useChannelDetailsMembersPreview'; + +export const ChannelDetailsMemberSection = () => { + const { channel, onAddMembersPress, onViewAllMembersPress } = useChannelDetailsContext(); + const { client } = useChatContext(); + const { t } = useTranslationContext(); + const { updateChannelMembers } = useOwnCapabilitiesContext(); + const { height: windowHeight } = useWindowDimensions(); + const { + theme: { + channelDetailsScreen: { + memberSection: { + footer: footerOverride, + header: headerOverride, + headerTitle: headerTitleOverride, + modalHeader: modalHeaderOverride, + modalHeaderTitle: modalHeaderTitleOverride, + viewAllLabel: viewAllLabelOverride, + }, + sectionCard: sectionCardOverride, + }, + semantics, + }, + } = useTheme(); + const { ChannelDetailsMemberList, ChannelDetailsMemberListItem } = useComponentsContext(); + const { hasMore, total, visible } = useChannelDetailsMembersPreview(channel); + const styles = useStyles(); + const [isMemberListVisible, setMemberListVisible] = useState(false); + + const handleViewAllPress = useStableCallback(() => { + if (onViewAllMembersPress) { + onViewAllMembersPress(); + return; + } + setMemberListVisible(true); + }); + + const handleMemberListClose = useStableCallback(() => setMemberListVisible(false)); + + const handleAddMembersPress = useStableCallback(() => { + onAddMembersPress?.(); + }); + + return ( + + + + {t('{{count}} members', { count: total })} + + {updateChannelMembers ? ( + +