From 40292147d13b12b3737d25543d630f4d6437bd92 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 11 May 2026 15:25:07 -0500 Subject: [PATCH 01/15] feat: channel info screen - first iteration --- .../src/screens/GroupChannelDetailsScreen.tsx | 403 +----------------- .../screens/OneOnOneChannelDetailScreen.tsx | 223 +--------- .../ChannelDetailsScreen.tsx | 99 +++++ .../ChannelDetailsActionsSection.tsx | 92 ++++ .../components/ChannelDetailsListItem.tsx | 130 ++++++ .../ChannelDetailsMemberListItem.tsx | 134 ++++++ .../ChannelDetailsMemberSection.tsx | 123 ++++++ .../ChannelDetailsNavigationSection.tsx | 71 +++ .../components/ChannelDetailsProfile.tsx | 92 ++++ .../components/ChannelDetailsScreenHeader.tsx | 104 +++++ .../ChannelDetailsScreen/components/index.ts | 7 + .../ChannelDetailsScreen/hooks/index.ts | 3 + .../hooks/useChannelDetailsCreatorId.ts | 10 + .../useChannelDetailsMemberStatusText.ts | 22 + .../hooks/useChannelDetailsMembersPreview.ts | 30 ++ .../components/ChannelDetailsScreen/index.ts | 3 + .../src/components/ChannelList/hooks/index.ts | 2 - .../ChannelDetailsBottomSheet.tsx | 2 +- .../ChannelPreview/ChannelSwipableWrapper.tsx | 4 +- .../ChannelDetailsBottomSheet.test.tsx | 2 +- .../__tests__/ChannelSwipableWrapper.test.tsx | 4 +- package/src/components/index.ts | 1 + .../channelDetailsContext.tsx | 40 ++ .../contexts/channelDetailsContext/index.ts | 1 + .../channelsContext/ChannelsContext.tsx | 2 +- .../componentsContext/defaultComponents.ts | 20 + package/src/contexts/index.ts | 1 + .../src/contexts/themeContext/utils/theme.ts | 68 +++ .../__tests__/useChannelActionItems.test.tsx | 12 +- .../useChannelActionItemsById.test.tsx | 0 package/src/hooks/index.ts | 2 + .../hooks/useChannelActionItems.tsx | 23 +- .../hooks/useChannelActionItemsById.ts | 0 package/src/i18n/en.json | 15 +- package/src/i18n/es.json | 15 +- package/src/i18n/fr.json | 15 +- package/src/i18n/he.json | 15 +- package/src/i18n/hi.json | 15 +- package/src/i18n/it.json | 15 +- package/src/i18n/ja.json | 15 +- package/src/i18n/ko.json | 15 +- package/src/i18n/nl.json | 15 +- package/src/i18n/pt-br.json | 15 +- package/src/i18n/ru.json | 15 +- package/src/i18n/tr.json | 15 +- package/src/icons/chevron-right.tsx | 20 + package/src/icons/index.ts | 1 + 47 files changed, 1299 insertions(+), 632 deletions(-) create mode 100644 package/src/components/ChannelDetailsScreen/ChannelDetailsScreen.tsx create mode 100644 package/src/components/ChannelDetailsScreen/components/ChannelDetailsActionsSection.tsx create mode 100644 package/src/components/ChannelDetailsScreen/components/ChannelDetailsListItem.tsx create mode 100644 package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberListItem.tsx create mode 100644 package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberSection.tsx create mode 100644 package/src/components/ChannelDetailsScreen/components/ChannelDetailsNavigationSection.tsx create mode 100644 package/src/components/ChannelDetailsScreen/components/ChannelDetailsProfile.tsx create mode 100644 package/src/components/ChannelDetailsScreen/components/ChannelDetailsScreenHeader.tsx create mode 100644 package/src/components/ChannelDetailsScreen/components/index.ts create mode 100644 package/src/components/ChannelDetailsScreen/hooks/index.ts create mode 100644 package/src/components/ChannelDetailsScreen/hooks/useChannelDetailsCreatorId.ts create mode 100644 package/src/components/ChannelDetailsScreen/hooks/useChannelDetailsMemberStatusText.ts create mode 100644 package/src/components/ChannelDetailsScreen/hooks/useChannelDetailsMembersPreview.ts create mode 100644 package/src/components/ChannelDetailsScreen/index.ts create mode 100644 package/src/contexts/channelDetailsContext/channelDetailsContext.tsx create mode 100644 package/src/contexts/channelDetailsContext/index.ts rename package/src/{components/ChannelList => }/hooks/__tests__/useChannelActionItems.test.tsx (93%) rename package/src/{components/ChannelList => }/hooks/__tests__/useChannelActionItemsById.test.tsx (100%) rename package/src/{components/ChannelList => }/hooks/useChannelActionItems.tsx (87%) rename package/src/{components/ChannelList => }/hooks/useChannelActionItemsById.ts (100%) create mode 100644 package/src/icons/chevron-right.tsx diff --git a/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx b/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx index 182c2ec708..d973cbc394 100644 --- a/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx +++ b/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx @@ -1,403 +1,46 @@ -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..3d3ca17770 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..227682ba57 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/ChannelDetailsScreen.tsx @@ -0,0 +1,99 @@ +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 { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../theme'; +import { useIsDirectChat } from '../ChannelList/hooks/useIsDirectChat'; + +export type ChannelDetailsScreenProps = { + channel: Channel; + /** Fired after channel.delete() resolves so consumers can pop the screen. */ + onAfterDeleteChat?: (channel: Channel) => void; + /** Fired after the current user is removed from the channel. */ + onAfterLeaveGroup?: (channel: Channel) => void; + onBack?: () => 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, + onAfterDeleteChat, + onAfterLeaveGroup, + onBack, +}: ChannelDetailsScreenProps) => { + const { ChannelDetailsScreenContent: ChannelDetailsScreenContentOverride } = + useComponentsContext(); + const value = useMemo( + () => ({ channel, onAfterDeleteChat, onAfterLeaveGroup, onBack }), + [channel, onAfterDeleteChat, onAfterLeaveGroup, onBack], + ); + 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/components/ChannelDetailsActionsSection.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsActionsSection.tsx new file mode 100644 index 0000000000..6e6c713827 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsActionsSection.tsx @@ -0,0 +1,92 @@ +import React, { useCallback, useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { GetChannelActionItems, useChannelActionItems } from '../../../hooks/useChannelActionItems'; +import { primitives } from '../../../theme'; + +export const ChannelDetailsActionsSection = () => { + const { channel, onAfterDeleteChat, onAfterLeaveGroup } = useChannelDetailsContext(); + const { + theme: { + channelDetailsScreen: { sectionCard: sectionCardOverride }, + semantics, + }, + } = useTheme(); + const { ChannelDetailsListItem } = useComponentsContext(); + const styles = useStyles(); + + const getActionItemsForDetailsScreen = useCallback( + ({ defaultItems }) => + defaultItems.map((item) => { + if (item.id === 'leave') { + return { + ...item, + action: async () => { + await item.action(); + onAfterLeaveGroup?.(channel); + }, + }; + } + if (item.id === 'deleteChannel') { + return { + ...item, + action: async () => { + await item.action(); + onAfterDeleteChat?.(channel); + }, + }; + } + return item; + }), + [channel, onAfterDeleteChat, onAfterLeaveGroup], + ); + + const items = useChannelActionItems({ + channel, + getChannelActionItems: getActionItemsForDetailsScreen, + }); + + if (items.length === 0) return null; + + return ( + + {items.map((item) => { + const testID = `channel-details-action-${item.id}`; + + 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..675c847c30 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsListItem.tsx @@ -0,0 +1,130 @@ +import React, { useMemo } from 'react'; +import { I18nManager, Pressable, StyleSheet, Text, View } from 'react-native'; + +import { useA11yLabel } from '../../../a11y/hooks/useA11yLabel'; +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; + accessibilityLabelKey?: string; + destructive?: boolean; + onPress?: () => void; + testID?: string; + trailing?: React.ReactNode; +}; + +export const ChannelDetailsListItem = ({ + Icon, + accessibilityLabelKey, + 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 translatedAccessibilityLabel = useA11yLabel(accessibilityLabelKey ?? ''); + 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/ChannelDetailsMemberListItem.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberListItem.tsx new file mode 100644 index 0000000000..729d3d0c51 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberListItem.tsx @@ -0,0 +1,134 @@ +import React, { useMemo } from 'react'; +import { I18nManager, StyleSheet, Text, View } from 'react-native'; + +import type { ChannelMemberResponse } from 'stream-chat'; + +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { primitives } from '../../../theme'; +import { UserAvatar } from '../../ui/Avatar/UserAvatar'; + +export type ChannelDetailsMemberListItemProps = { + member: ChannelMemberResponse; + isCurrentUser?: boolean; + isOwner?: boolean; +}; + +const ChannelDetailsMemberListItemInner = ({ + isCurrentUser, + isOwner, + member, +}: ChannelDetailsMemberListItemProps) => { + const { t } = useTranslationContext(); + const { + theme: { + channelDetailsScreen: { + memberItem: { + adminBadge: adminBadgeOverride, + container: containerOverride, + name: nameOverride, + status: statusOverride, + }, + }, + semantics, + }, + } = useTheme(); + const styles = useStyles(); + + const user = member.user; + if (!user) return null; + + const displayName = isCurrentUser ? t('You') : (user.name ?? user.id); + const statusLine = user.online ? t('Online') : ''; + + return ( + + + + + {displayName} + + {statusLine ? ( + + {statusLine} + + ) : null} + + {isOwner ? ( + + {t('Admin')} + + ) : null} + + ); +}; + +const areEqual = ( + prev: ChannelDetailsMemberListItemProps, + next: ChannelDetailsMemberListItemProps, +) => { + if (prev.isCurrentUser !== next.isCurrentUser) return false; + if (prev.isOwner !== next.isOwner) 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({ + adminBadge: { + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + textAlign: 'right', + width: 120, + }, + 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..f4972b80f4 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberSection.tsx @@ -0,0 +1,123 @@ +import React, { useMemo } from 'react'; +import { I18nManager, Pressable, StyleSheet, Text, View } from 'react-native'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { primitives } from '../../../theme'; +import { useChannelDetailsCreatorId } from '../hooks/useChannelDetailsCreatorId'; +import { useChannelDetailsMembersPreview } from '../hooks/useChannelDetailsMembersPreview'; + +export const ChannelDetailsMemberSection = () => { + const { channel } = useChannelDetailsContext(); + const { client } = useChatContext(); + const { t } = useTranslationContext(); + const { + theme: { + channelDetailsScreen: { + memberSection: { + footer: footerOverride, + header: headerOverride, + headerTitle: headerTitleOverride, + viewAllLabel: viewAllLabelOverride, + }, + sectionCard: sectionCardOverride, + }, + semantics, + }, + } = useTheme(); + const { ChannelDetailsMemberListItem } = useComponentsContext(); + const { hasMore, total, visible } = useChannelDetailsMembersPreview(channel); + const creatorId = useChannelDetailsCreatorId(channel); + const styles = useStyles(); + + return ( + + + + {t('{{count}} members', { count: total })} + + + + {visible.map((member) => { + if (!member.user?.id) return null; + return ( + + ); + })} + + {hasMore ? ( + + + + {t('View all')} + + + + ) : null} + + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + footer: { + alignItems: 'center', + borderTopWidth: 1, + paddingHorizontal: primitives.spacingMd, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: primitives.spacingMd, + paddingTop: primitives.spacingXs, + }, + headerTitle: { + flex: 1, + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + list: { + paddingBottom: primitives.spacingSm, + }, + sectionCard: { + borderRadius: primitives.radiusLg, + overflow: 'hidden', + }, + viewAllButton: { + alignItems: 'center', + justifyContent: 'center', + minHeight: 48, + width: '100%', + }, + viewAllLabel: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetailsScreen/components/ChannelDetailsNavigationSection.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsNavigationSection.tsx new file mode 100644 index 0000000000..0835d71981 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsNavigationSection.tsx @@ -0,0 +1,71 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { ChannelDetailsListItem } from './ChannelDetailsListItem'; + +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { ChevronRight } from '../../../icons'; +import { File } from '../../../icons/file'; +import { ImageGrid } from '../../../icons/gallery'; +import { Pin } from '../../../icons/pin'; +import { primitives } from '../../../theme'; + +export const ChannelDetailsNavigationSection = () => { + const { t } = useTranslationContext(); + const { + theme: { + channelDetailsScreen: { sectionCard: sectionCardOverride }, + semantics, + }, + } = useTheme(); + const styles = useStyles(); + + const chevron = useMemo( + () => , + [semantics.textTertiary], + ); + + return ( + + + + + + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + sectionCard: { + borderRadius: primitives.radiusLg, + overflow: 'hidden', + paddingVertical: primitives.spacingXs, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetailsScreen/components/ChannelDetailsProfile.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsProfile.tsx new file mode 100644 index 0000000000..45ab5c3eed --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsProfile.tsx @@ -0,0 +1,92 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { primitives } from '../../../theme'; +import { useChannelMembersState } from '../../ChannelList/hooks/useChannelMembersState'; +import { useIsDirectChat } from '../../ChannelList/hooks/useIsDirectChat'; +import { useChannelPreviewDisplayName } from '../../ChannelPreview/hooks/useChannelPreviewDisplayName'; +import { ChannelAvatar } from '../../ui/Avatar/ChannelAvatar'; +import { useChannelDetailsMemberStatusText } from '../hooks/useChannelDetailsMemberStatusText'; + +export const ChannelDetailsProfile = () => { + const { channel } = useChannelDetailsContext(); + const { t } = useTranslationContext(); + const { + theme: { + channelDetailsScreen: { + profile: { + container: containerOverride, + heading: headingOverride, + subtitle: subtitleOverride, + title: titleOverride, + }, + }, + semantics, + }, + } = useTheme(); + const isDirect = useIsDirectChat(channel); + const members = useChannelMembersState(channel); + const displayName = useChannelPreviewDisplayName(channel); + const groupStatusText = useChannelDetailsMemberStatusText(channel); + const styles = useStyles(); + + const subtitle = useMemo(() => { + if (!isDirect) return groupStatusText; + const otherMember = Object.values(members).find( + (member) => member.user?.id !== channel.getClient().userID, + ); + return otherMember?.user?.online ? t('Online') : ''; + }, [channel, groupStatusText, isDirect, members, t]); + + return ( + + + + + {displayName ?? ''} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + gap: primitives.spacingMd, + }, + heading: { + alignItems: 'center', + gap: primitives.spacingXs, + width: '100%', + }, + subtitle: { + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + textAlign: 'center', + }, + title: { + fontSize: primitives.typographyFontSizeXl, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightRelaxed, + textAlign: 'center', + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetailsScreen/components/ChannelDetailsScreenHeader.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsScreenHeader.tsx new file mode 100644 index 0000000000..282567c344 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsScreenHeader.tsx @@ -0,0 +1,104 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { ChevronLeft } from '../../../icons/chevron-left'; +import { primitives } from '../../../theme'; +import { useIsDirectChat } from '../../ChannelList/hooks/useIsDirectChat'; +import { Button } from '../../ui/Button/Button'; + +export type ChannelDetailsScreenHeaderProps = { + /** Override the auto-resolved screen title (1:1 → "Contact Info", group → "Group Info"). */ + title?: string; +}; + +export const ChannelDetailsScreenHeader = ({ title }: ChannelDetailsScreenHeaderProps) => { + const { channel, onBack } = useChannelDetailsContext(); + const { t } = useTranslationContext(); + const { + theme: { + channelDetailsScreen: { + header: { container: containerOverride, title: titleOverride }, + }, + semantics, + }, + } = useTheme(); + const isDirect = useIsDirectChat(channel); + const styles = useStyles(); + + const resolvedTitle = title ?? (isDirect ? t('Contact Info') : t('Group Info')); + + return ( + + + {onBack ? ( +