diff --git a/examples/SampleApp/src/components/AddMemberBottomSheet.tsx b/examples/SampleApp/src/components/AddMemberBottomSheet.tsx deleted file mode 100644 index 5f2e45cbc9..0000000000 --- a/examples/SampleApp/src/components/AddMemberBottomSheet.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import React, { useState } from 'react'; -import { - ActivityIndicator, - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Search, useTheme } from 'stream-chat-react-native'; - -import { UserSearchResultsGrid } from './UserSearch/UserSearchResultsGrid'; - -import { useAppOverlayContext } from '../context/AppOverlayContext'; -import { - isAddMemberBottomSheetData, - useBottomSheetOverlayContext, -} from '../context/BottomSheetOverlayContext'; -import { usePaginatedUsers } from '../hooks/usePaginatedUsers'; - -import type { UserResponse } from 'stream-chat'; -import { CircleClose } from '../icons/CircleClose'; - -const styles = StyleSheet.create({ - container: { - height: 300, - }, - flex: { - flex: 1, - }, - inputBox: { - flex: 1, - fontSize: 14, - includeFontPadding: false, // for android vertical text centering - marginLeft: 10, - padding: 0, // removal of default text input padding on android - paddingTop: 0, // removal of iOS top padding for weird centering - textAlignVertical: 'center', // for android vertical text centering - }, - inputBoxContainer: { - alignItems: 'center', - borderRadius: 18, - borderWidth: 1, - flexDirection: 'row', - paddingHorizontal: 10, - paddingVertical: 8, - }, - inputRow: { - alignItems: 'center', - flexDirection: 'row', - padding: 16, - justifyContent: 'center', - }, - text: { - marginLeft: 10, - }, - textContainer: { - alignItems: 'center', - flexDirection: 'row', - padding: 5, - width: '100%', - }, -}); - -export const AddMemberBottomSheet: React.FC = () => { - const { setOverlay } = useAppOverlayContext(); - const { data, reset } = useBottomSheetOverlayContext(); - - const channel = data && isAddMemberBottomSheetData(data) ? data.channel : undefined; - - const insets = useSafeAreaInsets(); - - const { - theme: { - colors: { accent_red, black, grey, grey_whisper, white, white_smoke }, - }, - } = useTheme(); - const { - clearText, - loading: loadingResults, - loadMore, - onChangeSearchText, - onFocusInput, - results, - searchText, - } = usePaginatedUsers(); - - const [addMemberQueryInProgress, setAddMemberQueryInProgress] = useState(false); - const [error, setError] = useState(false); - - if (!channel) { - return null; - } - - const addMember = async (user: UserResponse) => { - setAddMemberQueryInProgress(true); - - try { - await channel.addMembers([user.id]); - reset(); - setOverlay('none'); - } catch (err) { - console.warn('An error has occurred while adding members: ', err); - setError(true); - } - setAddMemberQueryInProgress(false); - }; - - return ( - - - - - - - - - - - - {addMemberQueryInProgress && ( - - - Adding user to channel - - )} - {error && ( - - - Error adding user to channel - - - )} - - - - - ); -}; diff --git a/examples/SampleApp/src/components/AddMembersBottomSheet.tsx b/examples/SampleApp/src/components/AddMembersBottomSheet.tsx new file mode 100644 index 0000000000..3160b87d95 --- /dev/null +++ b/examples/SampleApp/src/components/AddMembersBottomSheet.tsx @@ -0,0 +1,358 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + BottomSheetModal, + Checkmark, + Close, + StreamBottomSheetModalFlatList, + UserAvatar, + useStableCallback, + useTheme, +} from 'stream-chat-react-native'; + +import { CircleClose } from '../icons/CircleClose'; +import { usePaginatedUsers } from '../hooks/usePaginatedUsers'; + +import type { Channel, UserResponse } from 'stream-chat'; +import { UserSearch } from '../icons/UserSearch'; + +type AddMembersBottomSheetProps = { + channel: Channel; + onClose: () => void; + visible: boolean; +}; + +const keyExtractor = (item: UserResponse) => item.id; + +const SelectionCircle = React.memo(({ selected }: { selected: boolean }) => { + const { + theme: { semantics }, + } = useTheme(); + + if (selected) { + return ( + + + + ); + } + + return ; +}); + +SelectionCircle.displayName = 'SelectionCircle'; + +const selectionStyles = StyleSheet.create({ + circle: { + alignItems: 'center', + borderRadius: 9999, + borderWidth: 1, + height: 24, + justifyContent: 'center', + width: 24, + }, +}); + +export const AddMembersBottomSheet = React.memo( + ({ channel, onClose, visible }: AddMembersBottomSheetProps) => { + const { + theme: { semantics }, + } = useTheme(); + const styles = useStyles(); + + const { + clearText, + initialResults, + loading, + loadMore, + onChangeSearchText, + onFocusInput, + reset, + results, + searchText, + selectedUserIds, + toggleUser, + } = usePaginatedUsers(); + + const [adding, setAdding] = useState(false); + const [searchFocused, setSearchFocused] = useState(false); + + const stableOnClose = useStableCallback(onClose); + const hasSelection = selectedUserIds.length > 0; + + const existingMemberIds = useMemo( + () => new Set(Object.keys(channel.state.members)), + [channel.state.members], + ); + + const filteredResults = useMemo( + () => results.filter((user) => !existingMemberIds.has(user.id)), + [results, existingMemberIds], + ); + + const handleClose = useCallback(() => { + reset(); + setSearchFocused(false); + stableOnClose(); + }, [reset, stableOnClose]); + + const handleConfirm = useCallback(async () => { + if (!hasSelection) return; + + setAdding(true); + try { + await channel.addMembers(selectedUserIds); + reset(); + setSearchFocused(false); + stableOnClose(); + } catch (error) { + if (error instanceof Error) { + Alert.alert('Error', error.message); + } + } + setAdding(false); + }, [channel, hasSelection, reset, selectedUserIds, stableOnClose]); + + const handleSearchFocus = useCallback(() => { + setSearchFocused(true); + onFocusInput(); + }, [onFocusInput]); + + const handleSearchBlur = useCallback(() => { + setSearchFocused(false); + }, []); + + const renderItem = useCallback( + ({ item }: { item: UserResponse }) => { + const isSelected = selectedUserIds.includes(item.id); + return ( + toggleUser(item)} + style={({ pressed }) => [styles.userRow, pressed && { opacity: 0.7 }]} + > + + + + {item.name || item.id} + + + + + ); + }, + [selectedUserIds, semantics.textPrimary, styles, toggleUser], + ); + + const initialLoadComplete = initialResults !== null; + + const emptyComponent = useMemo(() => { + if (loading && !initialLoadComplete) { + return ( + + + + ); + } + return ( + + + No user found + + ); + }, [loading, initialLoadComplete, semantics.textSecondary, styles]); + + return ( + + + + + + + + Add Members + + + {adding ? ( + + ) : ( + + )} + + + + + + + + {searchText.length > 0 ? ( + + + + ) : null} + + + + + + + ); + }, +); + +AddMembersBottomSheet.displayName = 'AddMembersBottomSheet'; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + safeArea: { + flex: 1, + }, + header: { + alignItems: 'center', + flexDirection: 'row', + gap: 12, + justifyContent: 'space-between', + paddingHorizontal: 12, + paddingVertical: 12, + }, + iconButton: { + alignItems: 'center', + borderRadius: 9999, + borderWidth: 1, + height: 40, + justifyContent: 'center', + width: 40, + }, + confirmButton: { + alignItems: 'center', + borderRadius: 9999, + height: 40, + justifyContent: 'center', + width: 40, + }, + title: { + flex: 1, + fontSize: 17, + fontWeight: '600', + lineHeight: 20, + textAlign: 'center', + }, + searchContainer: { + paddingHorizontal: 16, + paddingBottom: 8, + }, + searchInput: { + alignItems: 'center', + borderRadius: 9999, + borderWidth: 1, + flexDirection: 'row', + gap: 8, + height: 48, + paddingHorizontal: 16, + }, + searchTextInput: { + flex: 1, + fontSize: 17, + lineHeight: 20, + padding: 0, + }, + userRow: { + alignItems: 'center', + flexDirection: 'row', + gap: 12, + minHeight: 52, + paddingHorizontal: 16, + paddingVertical: 8, + }, + userRowLeading: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + gap: 12, + }, + userName: { + flex: 1, + fontSize: 17, + fontWeight: '400', + lineHeight: 20, + }, + emptyState: { + alignItems: 'center', + gap: 12, + justifyContent: 'center', + paddingVertical: 40, + }, + emptyText: { + fontSize: 17, + lineHeight: 20, + textAlign: 'center', + }, + listContent: { + flexGrow: 1, + paddingBottom: 40, + }, + }), + [], + ); +}; diff --git a/examples/SampleApp/src/components/AllMembersBottomSheet.tsx b/examples/SampleApp/src/components/AllMembersBottomSheet.tsx new file mode 100644 index 0000000000..a482ad6f59 --- /dev/null +++ b/examples/SampleApp/src/components/AllMembersBottomSheet.tsx @@ -0,0 +1,169 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + BottomSheetModal, + Close, + StreamBottomSheetModalFlatList, + UserAdd, + useStableCallback, + useTheme, +} from 'stream-chat-react-native'; + +import { ContactDetailBottomSheet } from './ContactDetailBottomSheet'; +import { MemberListItem } from './MemberListItem'; + +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; +import type { StackNavigatorParamList } from '../types'; + +type AllMembersBottomSheetProps = { + channel: Channel; + channelCreatorId: string | undefined; + currentUserId: string | undefined; + navigation: NativeStackNavigationProp; + onClose: () => void; + visible: boolean; + onAddMember?: () => void; +}; + +const keyExtractor = (item: ChannelMemberResponse) => item.user_id ?? item.user?.id ?? ''; + +export const AllMembersBottomSheet = React.memo( + ({ + channel, + channelCreatorId, + currentUserId, + navigation, + onAddMember, + onClose, + visible, + }: AllMembersBottomSheetProps) => { + const { + theme: { semantics }, + } = useTheme(); + const styles = useStyles(); + + const [selectedMember, setSelectedMember] = useState(null); + + const members = useMemo(() => Object.values(channel.state.members), [channel.state.members]); + + const memberCount = channel?.data?.member_count ?? members.length; + + const stableOnClose = useStableCallback(onClose); + + const handleMemberPress = useCallback( + (member: ChannelMemberResponse) => { + if (member.user?.id !== currentUserId) { + setSelectedMember(member); + } + }, + [currentUserId], + ); + + const closeContactDetail = useCallback(() => { + setSelectedMember(null); + stableOnClose(); + }, [stableOnClose]); + + const renderItem = useCallback( + ({ item }: { item: ChannelMemberResponse }) => ( + handleMemberPress(item)} + /> + ), + [channelCreatorId, currentUserId, handleMemberPress], + ); + + return ( + + + + + + + + + {`${memberCount} Members`} + + + {onAddMember ? ( + + + + ) : ( + + )} + + + + + + + ); + }, +); + +AllMembersBottomSheet.displayName = 'AllMembersBottomSheet'; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + safeArea: { + flex: 1, + }, + header: { + alignItems: 'center', + flexDirection: 'row', + gap: 12, + justifyContent: 'space-between', + paddingHorizontal: 12, + paddingVertical: 12, + }, + iconButton: { + alignItems: 'center', + borderRadius: 9999, + borderWidth: 1, + height: 40, + justifyContent: 'center', + width: 40, + }, + iconButtonPlaceholder: { + height: 40, + width: 40, + }, + title: { + flex: 1, + fontSize: 17, + fontWeight: '600', + lineHeight: 20, + textAlign: 'center', + }, + listContent: { + paddingBottom: 40, + }, + }), + [], + ); +}; diff --git a/examples/SampleApp/src/components/BottomSheetOverlay.tsx b/examples/SampleApp/src/components/BottomSheetOverlay.tsx deleted file mode 100644 index 88f5fcb310..0000000000 --- a/examples/SampleApp/src/components/BottomSheetOverlay.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { useEffect } from 'react'; -import { Keyboard, StyleSheet } from 'react-native'; -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import Animated, { - cancelAnimation, - Easing, - Extrapolation, - interpolate, - runOnJS, - useAnimatedStyle, - useSharedValue, - withDecay, - withSpring, - withTiming, -} from 'react-native-reanimated'; -import { KeyboardCompatibleView, useTheme, useViewport } from 'stream-chat-react-native'; - -import { AddMemberBottomSheet } from './AddMemberBottomSheet'; -import { ConfirmationBottomSheet } from './ConfirmationBottomSheet'; - -import { useAppOverlayContext } from '../context/AppOverlayContext'; -import { useBottomSheetOverlayContext } from '../context/BottomSheetOverlayContext'; - -const styles = StyleSheet.create({ - addMembers: { borderRadius: 16, marginHorizontal: 8 }, - animatedContainer: { - flex: 1, - justifyContent: 'flex-end', - }, - container: { - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - }, -}); - -export type BottomSheetOverlayProps = { - overlayOpacity: Animated.SharedValue; - visible: boolean; -}; - -export const BottomSheetOverlay = (props: BottomSheetOverlayProps) => { - const { overlayOpacity, visible } = props; - - const { overlay, setOverlay } = useAppOverlayContext(); - const { vh } = useViewport(); - const screenHeight = vh(100); - - const { reset } = useBottomSheetOverlayContext(); - - const { - theme: { - colors: { white }, - }, - } = useTheme(); - - const offsetY = useSharedValue(0); - const showScreen = useSharedValue(0); - const translateY = useSharedValue(0); - const viewHeight = useSharedValue(0); - - const fadeScreen = (show: boolean) => { - 'worklet'; - if (show) { - offsetY.value = 0; - translateY.value = 0; - } - showScreen.value = show - ? withSpring(1, { - damping: 600, - mass: 0.5, - energyThreshold: 0.01, - stiffness: 200, - velocity: 32, - }) - : withTiming( - 0, - { - duration: 150, - easing: Easing.out(Easing.ease), - }, - () => { - if (!show) { - runOnJS(reset)(); - } - }, - ); - }; - - useEffect(() => { - if (visible) { - Keyboard.dismiss(); - } - fadeScreen(!!visible); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [visible]); - - const pan = Gesture.Pan() - .enabled(visible) - .maxPointers(1) - .minDistance(10) - .onBegin(() => { - cancelAnimation(translateY); - offsetY.value = translateY.value; - }) - .onUpdate((evt) => { - translateY.value = offsetY.value + evt.translationY; - overlayOpacity.value = interpolate( - translateY.value, - [0, viewHeight.value / 2], - [1, 0.75], - Extrapolation.CLAMP, - ); - }) - .onEnd((evt) => { - const finalYPosition = evt.translationY + evt.velocityY * 0.1; - - if (finalYPosition > viewHeight.value / 2 && translateY.value > 0) { - cancelAnimation(translateY); - overlayOpacity.value = withTiming( - 0, - { - duration: 200, - easing: Easing.out(Easing.ease), - }, - () => { - runOnJS(setOverlay)('none'); - }, - ); - translateY.value = - evt.velocityY > 1000 - ? withDecay({ - velocity: evt.velocityY, - }) - : withTiming(screenHeight, { - duration: 200, - easing: Easing.out(Easing.ease), - }); - } else { - translateY.value = withTiming(0); - overlayOpacity.value = withTiming(1); - } - }); - - const tap = Gesture.Tap() - .enabled(visible) - .maxDistance(32) - .onEnd(() => { - setOverlay('none'); - }); - - const panStyle = useAnimatedStyle(() => ({ - transform: [ - { - translateY: translateY.value > 0 ? translateY.value : 0, - }, - ], - })); - - const showScreenStyle = useAnimatedStyle(() => ({ - transform: [ - { - translateY: interpolate(showScreen.value, [0, 1], [viewHeight.value / 2, 0]), - }, - ], - })); - - if (!visible) { - return null; - } - - return ( - - - - - - - { - viewHeight.value = height; - }} - style={[ - styles.container, - showScreenStyle, - { - backgroundColor: white, - }, - overlay === 'addMembers' ? styles.addMembers : undefined, - ]} - > - {overlay === 'addMembers' && } - {overlay === 'confirmation' && } - - - - - - - - ); -}; diff --git a/examples/SampleApp/src/components/ChannelDetailProfileSection.tsx b/examples/SampleApp/src/components/ChannelDetailProfileSection.tsx new file mode 100644 index 0000000000..0d224cf3fd --- /dev/null +++ b/examples/SampleApp/src/components/ChannelDetailProfileSection.tsx @@ -0,0 +1,66 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { useTheme } from 'stream-chat-react-native'; + +type ChannelDetailProfileSectionProps = { + avatar: React.ReactNode; + subtitle: string; + title: string; +}; + +export const ChannelDetailProfileSection = React.memo( + ({ avatar, subtitle, title }: ChannelDetailProfileSectionProps) => { + const { + theme: { semantics }, + } = useTheme(); + const styles = useStyles(); + + return ( + + {avatar} + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + ); + }, +); + +ChannelDetailProfileSection.displayName = 'ChannelDetailProfileSection'; + +const useStyles = () => + useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + gap: 16, + paddingHorizontal: 0, + }, + heading: { + alignItems: 'center', + gap: 8, + width: '100%', + }, + title: { + fontSize: 22, + fontWeight: '600', + lineHeight: 24, + textAlign: 'center', + }, + subtitle: { + fontSize: 15, + fontWeight: '400', + lineHeight: 20, + textAlign: 'center', + }, + }), + [], + ); diff --git a/examples/SampleApp/src/components/ChannelInfoOverlay.tsx b/examples/SampleApp/src/components/ChannelInfoOverlay.tsx index d9a083ba46..705a9b8c77 100644 --- a/examples/SampleApp/src/components/ChannelInfoOverlay.tsx +++ b/examples/SampleApp/src/components/ChannelInfoOverlay.tsx @@ -1,10 +1,9 @@ -import React from 'react'; +import React, { useCallback, useState } from 'react'; import { FlatList, StyleSheet, Text, View } from 'react-native'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; import { Pressable } from 'react-native-gesture-handler'; -import Animated, { -} from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; import { CircleClose, Delete, @@ -18,6 +17,7 @@ import { } from 'stream-chat-react-native'; import { ChannelMemberResponse } from 'stream-chat'; +import { ConfirmationBottomSheet } from './ConfirmationBottomSheet'; import { useAppOverlayContext } from '../context/AppOverlayContext'; import { useChannelInfoOverlayContext } from '../context/ChannelInfoOverlayContext'; import { Archive } from '../icons/Archive'; @@ -25,6 +25,8 @@ import { useChannelInfoOverlayActions } from '../hooks/useChannelInfoOverlayActi import { SafeAreaView } from 'react-native-safe-area-context'; import { Pin } from '../icons/Pin.tsx'; +import type { ConfirmationData } from './ConfirmationBottomSheet'; + dayjs.extend(relativeTime); const styles = StyleSheet.create({ @@ -139,8 +141,18 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { ) : []; + const [confirmationData, setConfirmationData] = useState(null); + + const showConfirmation = useCallback((_data: ConfirmationData) => { + setConfirmationData(_data); + }, []); + + const closeConfirmation = useCallback(() => { + setConfirmationData(null); + }, []); + const { viewInfo, pinUnpin, archiveUnarchive, leaveGroup, deleteConversation, cancel } = - useChannelInfoOverlayActions({ channel, navigation, otherMembers }); + useChannelInfoOverlayActions({ channel, navigation, otherMembers, showConfirmation }); const onClose = useStableCallback(() => { setOverlay('none'); @@ -289,6 +301,15 @@ export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { )} + ); }; diff --git a/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx b/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx index ae04c9eb6b..8119e389d0 100644 --- a/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx +++ b/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx @@ -1,110 +1,127 @@ -import React from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Delete, useTheme } from 'stream-chat-react-native'; +import React, { useCallback, useMemo } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { BottomSheetModal, Delete, useStableCallback, useTheme } from 'stream-chat-react-native'; -import { useAppOverlayContext } from '../context/AppOverlayContext'; -import { - isAddMemberBottomSheetData, - useBottomSheetOverlayContext, -} from '../context/BottomSheetOverlayContext'; import { UserMinus } from '../icons/UserMinus'; -const styles = StyleSheet.create({ - actionButtonLeft: { - padding: 20, - }, - actionButtonRight: { - padding: 20, - }, - actionButtonsContainer: { - borderTopWidth: 1, - flexDirection: 'row', - justifyContent: 'space-between', - }, - container: { - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - height: 224, - }, - description: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - }, - subtext: { - fontSize: 14, - fontWeight: '500', - marginTop: 8, - paddingHorizontal: 16, - }, - title: { - fontSize: 16, - fontWeight: '700', - marginTop: 18, - paddingHorizontal: 16, - }, -}); +const SHEET_HEIGHT = 224; -export const ConfirmationBottomSheet: React.FC = () => { - const { setOverlay } = useAppOverlayContext(); - const { data: contextData, reset } = useBottomSheetOverlayContext(); - const data = contextData && !isAddMemberBottomSheetData(contextData) ? contextData : undefined; +export type ConfirmationData = { + onConfirm: () => void; + title: string; + cancelText?: string; + confirmText?: string; + subtext?: string; +}; + +type ConfirmationBottomSheetProps = { + onClose: () => void; + visible: boolean; + cancelText?: string; + confirmText?: string; + onConfirm?: () => void; + subtext?: string; + title?: string; +}; - const { - theme: { - colors: { accent_red, black, grey, white }, - semantics, - }, - } = useTheme(); - const inset = useSafeAreaInsets(); +export const ConfirmationBottomSheet = React.memo( + ({ + cancelText = 'CANCEL', + confirmText = 'CONFIRM', + onClose, + onConfirm, + subtext, + title, + visible, + }: ConfirmationBottomSheetProps) => { + const { + theme: { semantics }, + } = useTheme(); + const styles = useStyles(); + const stableOnClose = useStableCallback(onClose); - if (!data) { - return null; - } + const handleCancel = useCallback(() => { + stableOnClose(); + }, [stableOnClose]); - const { cancelText = 'CANCEL', confirmText = 'CONFIRM', onConfirm, subtext, title } = data; + const handleConfirm = useCallback(() => { + onConfirm?.(); + stableOnClose(); + }, [onConfirm, stableOnClose]); - return ( - + + + {isLeave ? ( + + ) : ( + + )} + {title} + {subtext ? ( + {subtext} + ) : null} + + + + + {cancelText} + + + + + {confirmText} + + + + + + ); + }, +); + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + actionButton: { + padding: 20, + }, + actionText: { + fontSize: 14, + fontWeight: '600', + }, + actions: { + borderTopWidth: 1, + flexDirection: 'row', + justifyContent: 'space-between', + }, + description: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + safeArea: { + flex: 1, + }, + subtext: { + fontSize: 14, + fontWeight: '500', + marginTop: 8, + paddingHorizontal: 16, + textAlign: 'center', + }, + title: { + fontSize: 16, + fontWeight: '700', + marginTop: 18, + paddingHorizontal: 16, }, - ]} - > - - {confirmText === 'LEAVE' ? ( - - ) : ( - - )} - {title} - {subtext} - - - { - setOverlay('none'); - reset(); - }} - style={styles.actionButtonLeft} - > - {cancelText} - - - {confirmText} - - - + }), + [], ); }; diff --git a/examples/SampleApp/src/components/ContactDetailBottomSheet.tsx b/examples/SampleApp/src/components/ContactDetailBottomSheet.tsx new file mode 100644 index 0000000000..9dc7b95ab0 --- /dev/null +++ b/examples/SampleApp/src/components/ContactDetailBottomSheet.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useMemo } from 'react'; +import { Alert, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + BottomSheetModal, + CircleBan, + MessageIcon, + useChatContext, + useStableCallback, + useTheme, + UserAvatar, +} from 'stream-chat-react-native'; + +import { ListItem } from './ListItem'; + +import { Mute } from '../icons/Mute'; +import { getUserActivityStatus } from '../utils/getUserActivityStatus'; + +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; +import type { StackNavigatorParamList } from '../types'; + +const SHEET_HEIGHT = 260; + +type ContactDetailBottomSheetProps = { + channel: Channel; + member: ChannelMemberResponse | null; + navigation: NativeStackNavigationProp; + onClose: () => void; + visible: boolean; +}; + +export const ContactDetailBottomSheet = React.memo( + ({ member, navigation, onClose, visible }: ContactDetailBottomSheetProps) => { + const { + theme: { semantics }, + } = useTheme(); + const { client } = useChatContext(); + const styles = useStyles(); + + const stableOnClose = useStableCallback(onClose); + + const user = member?.user; + const activityStatus = user ? getUserActivityStatus(user) : ''; + const isMuted = client.mutedUsers?.some((m) => m.target.id === user?.id) ?? false; + + const sendDirectMessage = useCallback(async () => { + if (!client.user?.id || !user?.id) return; + + const members = [client.user.id, user.id]; + + try { + const channels = await client.queryChannels({ members }); + + const dmChannel = + channels.length === 1 ? channels[0] : client.channel('messaging', { members }); + + await dmChannel.watch(); + + stableOnClose(); + navigation.navigate('ChannelScreen', { + channel: dmChannel, + channelId: dmChannel.id, + }); + } catch (error) { + if (error instanceof Error) { + Alert.alert('Error', error.message); + } + } + }, [client, navigation, stableOnClose, user?.id]); + + const muteUser = useCallback(async () => { + if (!user?.id) return; + + try { + const _isMuted = client.mutedUsers?.some((m) => m.target.id === user.id); + if (_isMuted) { + await client.unmuteUser(user.id); + } else { + await client.muteUser(user.id); + } + stableOnClose(); + } catch (error) { + if (error instanceof Error) { + Alert.alert('Error', error.message); + } + } + }, [client, stableOnClose, user?.id]); + + const blockUser = useCallback(async () => { + if (!user?.id) return; + + try { + await client.blockUser(user.id); + stableOnClose(); + } catch (error) { + if (error instanceof Error) { + Alert.alert('Error', error.message); + } + } + }, [client, stableOnClose, user?.id]); + + if (!user) return null; + + return ( + + + + + + + {user.name || user.id} + + {activityStatus ? ( + + {activityStatus} + + ) : null} + + + + } + label='Send Direct Message' + onPress={sendDirectMessage} + /> + } + label={isMuted ? 'Unmute User' : 'Mute User'} + onPress={muteUser} + /> + } + label='Block User' + onPress={blockUser} + /> + + + ); + }, +); + +ContactDetailBottomSheet.displayName = 'ContactDetailBottomSheet'; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + safeArea: { + flex: 1, + }, + header: { + alignItems: 'center', + flexDirection: 'row', + gap: 12, + paddingHorizontal: 12, + paddingVertical: 12, + }, + headerText: { + flex: 1, + gap: 4, + }, + name: { + fontSize: 17, + fontWeight: '600', + lineHeight: 20, + }, + status: { + fontSize: 15, + fontWeight: '400', + lineHeight: 20, + }, + }), + [], + ); +}; diff --git a/examples/SampleApp/src/components/EditGroupBottomSheet.tsx b/examples/SampleApp/src/components/EditGroupBottomSheet.tsx new file mode 100644 index 0000000000..2f1d1988e8 --- /dev/null +++ b/examples/SampleApp/src/components/EditGroupBottomSheet.tsx @@ -0,0 +1,221 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + BottomSheetModal, + ChannelAvatar, + Checkmark, + Close, + useStableCallback, + useTheme, +} from 'stream-chat-react-native'; + +import type { Channel } from 'stream-chat'; + +type EditGroupBottomSheetProps = { + channel: Channel; + onClose: () => void; + visible: boolean; +}; + +export const EditGroupBottomSheet = React.memo( + ({ channel, onClose, visible }: EditGroupBottomSheetProps) => { + const { + theme: { semantics }, + } = useTheme(); + const styles = useStyles(); + + const [name, setName] = useState((channel.data?.name as string) ?? ''); + const [saving, setSaving] = useState(false); + const [inputFocused, setInputFocused] = useState(false); + + const stableOnClose = useStableCallback(onClose); + + const handleClose = useCallback(() => { + setName((channel.data?.name as string) ?? ''); + setInputFocused(false); + stableOnClose(); + }, [channel.data?.name, stableOnClose]); + + const hasChanges = name.trim() !== ((channel.data?.name as string) ?? ''); + + const handleConfirm = useCallback(async () => { + const trimmed = name.trim(); + if (!trimmed || !hasChanges) return; + + setSaving(true); + try { + await channel.updatePartial({ set: { name: trimmed } }); + setInputFocused(false); + stableOnClose(); + } catch (error) { + if (error instanceof Error) { + Alert.alert('Error', error.message); + } + } + setSaving(false); + }, [channel, hasChanges, name, stableOnClose]); + + const handleFocus = useCallback(() => setInputFocused(true), []); + const handleBlur = useCallback(() => setInputFocused(false), []); + + return ( + + + + + + + + Edit + + + {saving ? ( + + ) : ( + + )} + + + + + + + {/* TODO: Avatar changing will be done later */} + Alert.alert('Coming Soon', 'Will be implemented in future')} + style={styles.uploadButton} + > + Upload + + + + + + + + + + ); + }, +); + +EditGroupBottomSheet.displayName = 'EditGroupBottomSheet'; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + avatarSection: { + alignItems: 'center', + gap: 8, + }, + body: { + gap: 24, + paddingHorizontal: 16, + paddingTop: 24, + }, + confirmButton: { + alignItems: 'center', + borderRadius: 9999, + height: 40, + justifyContent: 'center', + width: 40, + }, + header: { + alignItems: 'center', + flexDirection: 'row', + gap: 12, + justifyContent: 'space-between', + paddingHorizontal: 12, + paddingVertical: 12, + }, + iconButton: { + alignItems: 'center', + borderRadius: 9999, + borderWidth: 1, + height: 40, + justifyContent: 'center', + width: 40, + }, + inputContainer: { + minHeight: 48, + }, + safeArea: { + flex: 1, + }, + textInput: { + borderRadius: 12, + borderWidth: 1, + fontSize: 17, + lineHeight: 20, + minHeight: 48, + paddingHorizontal: 16, + paddingVertical: 12, + }, + title: { + flex: 1, + fontSize: 17, + fontWeight: '600', + lineHeight: 20, + textAlign: 'center', + }, + uploadButton: { + alignItems: 'center', + justifyContent: 'center', + minHeight: 40, + paddingHorizontal: 16, + paddingVertical: 10, + }, + uploadLabel: { + fontSize: 17, + fontWeight: '600', + lineHeight: 20, + }, + }), + [], + ); +}; diff --git a/examples/SampleApp/src/components/ListItem.tsx b/examples/SampleApp/src/components/ListItem.tsx new file mode 100644 index 0000000000..75f4dcdf2c --- /dev/null +++ b/examples/SampleApp/src/components/ListItem.tsx @@ -0,0 +1,68 @@ +import React, { useMemo } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useTheme } from 'stream-chat-react-native'; + +type ListItemProps = { + icon: React.ReactNode; + label: string; + destructive?: boolean; + onPress?: () => void; + trailing?: React.ReactNode; +}; + +export const ListItem = React.memo( + ({ icon, label, destructive = false, onPress, trailing }: ListItemProps) => { + const { + theme: { semantics }, + } = useTheme(); + const styles = useStyles(); + + const labelColor = destructive ? semantics.accentError : semantics.textPrimary; + + return ( + [styles.outerContainer, pressed && { opacity: 0.7 }]} + > + + {icon} + + {label} + + {trailing ? {trailing} : null} + + + ); + }, +); + +ListItem.displayName = 'ListItem'; + +const useStyles = () => + useMemo( + () => + StyleSheet.create({ + outerContainer: { + minHeight: 40, + paddingHorizontal: 4, + }, + contentContainer: { + alignItems: 'center', + borderRadius: 12, + flexDirection: 'row', + gap: 12, + padding: 12, + }, + label: { + flex: 1, + fontSize: 17, + fontWeight: '400', + lineHeight: 20, + }, + trailing: { + flexShrink: 0, + }, + }), + [], + ); diff --git a/examples/SampleApp/src/components/MemberListItem.tsx b/examples/SampleApp/src/components/MemberListItem.tsx new file mode 100644 index 0000000000..bfd4fbb963 --- /dev/null +++ b/examples/SampleApp/src/components/MemberListItem.tsx @@ -0,0 +1,110 @@ +import React, { useMemo } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useChatContext, useTheme, UserAvatar } from 'stream-chat-react-native'; + +import { Mute } from '../icons/Mute'; +import { getUserActivityStatus } from '../utils/getUserActivityStatus'; + +import type { ChannelMemberResponse } from 'stream-chat'; + +type MemberListItemProps = { + member: ChannelMemberResponse; + isCurrentUser?: boolean; + isOwner?: boolean; + onPress?: () => void; +}; + +export const MemberListItem = React.memo( + ({ member, isCurrentUser = false, isOwner = false, onPress }: MemberListItemProps) => { + const { + theme: { semantics }, + } = useTheme(); + const { client } = useChatContext(); + const styles = useStyles(); + + const user = member.user; + if (!user) { + return null; + } + + const displayName = isCurrentUser ? 'You' : user.name || user.id; + const activityStatus = getUserActivityStatus(user); + const isMuted = client.mutedUsers?.some((m) => m.target.id === user.id) ?? false; + + return ( + [styles.outerContainer, pressed && { opacity: 0.7 }]} + > + + + + + + {displayName} + + {activityStatus ? ( + + {activityStatus} + + ) : null} + + + {isMuted ? : null} + {isOwner ? ( + Admin + ) : null} + + + ); + }, +); + +MemberListItem.displayName = 'MemberListItem'; + +const useStyles = () => + useMemo( + () => + StyleSheet.create({ + outerContainer: { + minHeight: 40, + paddingHorizontal: 4, + }, + contentContainer: { + alignItems: 'center', + borderRadius: 12, + flexDirection: 'row', + gap: 12, + paddingHorizontal: 12, + paddingVertical: 8, + }, + leading: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + gap: 12, + }, + textContainer: { + flex: 1, + }, + name: { + fontSize: 17, + fontWeight: '400', + lineHeight: 20, + }, + status: { + fontSize: 13, + fontWeight: '400', + lineHeight: 16, + }, + roleLabel: { + fontSize: 17, + fontWeight: '400', + lineHeight: 20, + textAlign: 'right', + width: 120, + }, + }), + [], + ); diff --git a/examples/SampleApp/src/components/SectionCard.tsx b/examples/SampleApp/src/components/SectionCard.tsx new file mode 100644 index 0000000000..594250057d --- /dev/null +++ b/examples/SampleApp/src/components/SectionCard.tsx @@ -0,0 +1,35 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, View, ViewProps } from 'react-native'; +import { useTheme } from 'stream-chat-react-native'; + +type SectionCardProps = ViewProps & { + children: React.ReactNode; +}; + +export const SectionCard = React.memo(({ children, style, ...rest }: SectionCardProps) => { + const { + theme: { semantics }, + } = useTheme(); + const themedStyles = useThemedStyles(); + + return ( + + {children} + + ); +}); + +SectionCard.displayName = 'SectionCard'; + +const useThemedStyles = () => + useMemo( + () => + StyleSheet.create({ + container: { + borderRadius: 12, + overflow: 'hidden', + paddingVertical: 8, + }, + }), + [], + ); diff --git a/examples/SampleApp/src/components/UserInfoOverlay.tsx b/examples/SampleApp/src/components/UserInfoOverlay.tsx index ecc1c96e1e..8ac7ef9444 100644 --- a/examples/SampleApp/src/components/UserInfoOverlay.tsx +++ b/examples/SampleApp/src/components/UserInfoOverlay.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Keyboard, StyleSheet, Text, View, ViewStyle } from 'react-native'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; @@ -23,6 +23,7 @@ import { UserAvatar, } from 'stream-chat-react-native'; +import { ConfirmationBottomSheet } from './ConfirmationBottomSheet'; import { useAppOverlayContext } from '../context/AppOverlayContext'; import { useUserInfoOverlayContext } from '../context/UserInfoOverlayContext'; @@ -33,6 +34,8 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { UserMinus } from '../icons/UserMinus'; import { CircleClose } from '../icons/CircleClose'; +import type { ConfirmationData } from './ConfirmationBottomSheet'; + dayjs.extend(relativeTime); const styles = StyleSheet.create({ @@ -221,7 +224,19 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { ) : undefined; - const { viewInfo, messageUser, removeFromGroup, cancel } = useUserInfoOverlayActions(); + const [confirmationData, setConfirmationData] = useState(null); + + const showConfirmation = useCallback((_data: ConfirmationData) => { + setConfirmationData(_data); + }, []); + + const closeConfirmation = useCallback(() => { + setConfirmationData(null); + }, []); + + const { viewInfo, messageUser, removeFromGroup, cancel } = useUserInfoOverlayActions({ + showConfirmation, + }); if (!self || !member) { return null; @@ -346,6 +361,15 @@ export const UserInfoOverlay = (props: UserInfoOverlayProps) => { + ); }; diff --git a/examples/SampleApp/src/context/AppOverlayContext.tsx b/examples/SampleApp/src/context/AppOverlayContext.tsx index 6b1556d38e..badb915810 100644 --- a/examples/SampleApp/src/context/AppOverlayContext.tsx +++ b/examples/SampleApp/src/context/AppOverlayContext.tsx @@ -2,7 +2,7 @@ import React, { useContext } from 'react'; export type BlurType = 'light' | 'dark' | undefined; -export type Overlay = 'addMembers' | 'alert' | 'channelInfo' | 'confirmation' | 'none' | 'userInfo'; +export type Overlay = 'channelInfo' | 'none' | 'userInfo'; export type AppOverlayContextValue = { overlay: Overlay; diff --git a/examples/SampleApp/src/context/AppOverlayProvider.tsx b/examples/SampleApp/src/context/AppOverlayProvider.tsx index 62c6fc948c..2aaa0f6f69 100644 --- a/examples/SampleApp/src/context/AppOverlayProvider.tsx +++ b/examples/SampleApp/src/context/AppOverlayProvider.tsx @@ -9,10 +9,8 @@ import Animated, { import { AppOverlayContext, AppOverlayContextValue } from './AppOverlayContext'; -import { BottomSheetOverlay } from '../components/BottomSheetOverlay'; import { ChannelInfoOverlay } from '../components/ChannelInfoOverlay'; import { UserInfoOverlay } from '../components/UserInfoOverlay'; -import { BottomSheetOverlayProvider } from './BottomSheetOverlayContext'; import { ChannelInfoOverlayProvider } from './ChannelInfoOverlayContext'; import { UserInfoOverlayProvider } from './UserInfoOverlayContext'; import { OverlayBackdrop } from '../components/OverlayBackdrop'; @@ -68,28 +66,22 @@ export const AppOverlayProvider = ( return ( - - - - {children} - - - - - - - - - + + + {children} + + + + + + + ); }; diff --git a/examples/SampleApp/src/context/BottomSheetOverlayContext.tsx b/examples/SampleApp/src/context/BottomSheetOverlayContext.tsx deleted file mode 100644 index 3c7dad3387..0000000000 --- a/examples/SampleApp/src/context/BottomSheetOverlayContext.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React, { PropsWithChildren, useContext, useState } from 'react'; - -import type { ChannelContextValue } from 'stream-chat-react-native'; - -export const isAddMemberBottomSheetData = ( - data: BottomSheetOverlayData, -): data is Pick => 'channel' in data; - -export type BottomSheetOverlayData = - | Pick - | { - onConfirm: () => void; - title: string; - cancelText?: string; - confirmText?: string; - subtext?: string; - }; - -export type BottomSheetOverlayContextValue = { - reset: () => void; - setData: React.Dispatch>; - data?: BottomSheetOverlayData; -}; - -export const BottomSheetOverlayContext = React.createContext({} as BottomSheetOverlayContextValue); - -export const BottomSheetOverlayProvider = ({ - children, - value, -}: PropsWithChildren<{ - value?: BottomSheetOverlayContextValue; -}>) => { - const [data, setData] = useState(value?.data); - - const reset = () => { - setData(value?.data); - }; - - const bottomSheetOverlayContext = { - data, - reset, - setData, - }; - return ( - - {children} - - ); -}; - -export const useBottomSheetOverlayContext = () => - useContext(BottomSheetOverlayContext) as unknown as BottomSheetOverlayContextValue; diff --git a/examples/SampleApp/src/hooks/useChannelInfoOverlayActions.tsx b/examples/SampleApp/src/hooks/useChannelInfoOverlayActions.tsx index e8dfb235ca..36bfa164c7 100644 --- a/examples/SampleApp/src/hooks/useChannelInfoOverlayActions.tsx +++ b/examples/SampleApp/src/hooks/useChannelInfoOverlayActions.tsx @@ -3,19 +3,20 @@ import { useChannelInfoOverlayContext, } from '../context/ChannelInfoOverlayContext'; import { Channel, ChannelMemberResponse } from 'stream-chat'; -import { useBottomSheetOverlayContext } from '../context/BottomSheetOverlayContext'; import { useAppOverlayContext } from '../context/AppOverlayContext'; +import type { ConfirmationData } from '../components/ConfirmationBottomSheet'; + export type UseChannelInfoOverlayGesturesParams = { - navigation?: ChannelListScreenNavigationProp; + showConfirmation: (data: ConfirmationData) => void; channel?: Channel; + navigation?: ChannelListScreenNavigationProp; otherMembers?: ChannelMemberResponse[]; }; export const useChannelInfoOverlayActions = (params: UseChannelInfoOverlayGesturesParams) => { - const { navigation, channel, otherMembers } = params; + const { navigation, channel, otherMembers, showConfirmation } = params; const { data } = useChannelInfoOverlayContext(); - const { setData } = useBottomSheetOverlayContext(); const { setOverlay } = useAppOverlayContext(); const { clientId, membership } = data || {}; @@ -86,7 +87,7 @@ export const useChannelInfoOverlayActions = (params: UseChannelInfoOverlayGestur if (!channel) { return; } - setData({ + showConfirmation({ confirmText: 'DELETE', onConfirm: () => { channel.delete(); @@ -97,7 +98,6 @@ export const useChannelInfoOverlayActions = (params: UseChannelInfoOverlayGestur }?`, title: `Delete ${otherMembers?.length === 1 ? 'Conversation' : 'Group'}`, }); - setOverlay('confirmation'); }; const cancel = () => { diff --git a/examples/SampleApp/src/hooks/useUserInfoOverlayActions.tsx b/examples/SampleApp/src/hooks/useUserInfoOverlayActions.tsx index 3ea05a1142..86f11d16a5 100644 --- a/examples/SampleApp/src/hooks/useUserInfoOverlayActions.tsx +++ b/examples/SampleApp/src/hooks/useUserInfoOverlayActions.tsx @@ -1,13 +1,18 @@ +import { Alert } from 'react-native'; import { useChatContext } from 'stream-chat-react-native'; + import { useAppOverlayContext } from '../context/AppOverlayContext'; -import { useBottomSheetOverlayContext } from '../context/BottomSheetOverlayContext'; import { useUserInfoOverlayContext } from '../context/UserInfoOverlayContext'; -import { Alert } from 'react-native'; -export const useUserInfoOverlayActions = () => { +import type { ConfirmationData } from '../components/ConfirmationBottomSheet'; + +type UseUserInfoOverlayActionsParams = { + showConfirmation: (data: ConfirmationData) => void; +}; + +export const useUserInfoOverlayActions = ({ showConfirmation }: UseUserInfoOverlayActionsParams) => { const { client } = useChatContext(); const { setOverlay } = useAppOverlayContext(); - const { setData } = useBottomSheetOverlayContext(); const { data } = useUserInfoOverlayContext(); const { channel, member, navigation } = data ?? {}; @@ -18,7 +23,6 @@ export const useUserInfoOverlayActions = () => { const members = [client.user.id, member.user?.id || '']; - // Check if the channel already exists. const channels = await client.queryChannels({ members, }); @@ -53,7 +57,6 @@ export const useUserInfoOverlayActions = () => { const members = [client.user.id, member.user?.id || '']; - // Check if the channel already exists. const channels = await client.queryChannels({ members, }); @@ -78,7 +81,7 @@ export const useUserInfoOverlayActions = () => { if (!channel || !member) { return; } - setData({ + showConfirmation({ confirmText: 'REMOVE', onConfirm: () => { if (member.user?.id) { @@ -89,7 +92,6 @@ export const useUserInfoOverlayActions = () => { subtext: `Are you sure you want to remove User from ${channel?.data?.name || 'group'}?`, title: 'Remove User', }); - setOverlay('confirmation'); }; const cancel = () => { diff --git a/examples/SampleApp/src/icons/File.tsx b/examples/SampleApp/src/icons/File.tsx index 2e0d208188..4063b3ddc7 100644 --- a/examples/SampleApp/src/icons/File.tsx +++ b/examples/SampleApp/src/icons/File.tsx @@ -1,30 +1,18 @@ import React from 'react'; import Svg, { G, Path } from 'react-native-svg'; -import { useTheme } from 'stream-chat-react-native'; import { IconProps } from '../utils/base'; -export const File: React.FC = ({ fill, height = 24, scale = 1, width = 24 }) => { - const { - theme: { - colors: { black }, - }, - } = useTheme(); - +export const File: React.FC = ({ height = 20, width = 20, ...rest }) => { return ( - - + + diff --git a/examples/SampleApp/src/icons/GoForward.tsx b/examples/SampleApp/src/icons/GoForward.tsx index 1c0597536d..ff994a76bb 100644 --- a/examples/SampleApp/src/icons/GoForward.tsx +++ b/examples/SampleApp/src/icons/GoForward.tsx @@ -1,25 +1,20 @@ import React from 'react'; -import Svg, { Path } from 'react-native-svg'; -import { useTheme } from 'stream-chat-react-native'; +import Svg, { G, Path } from 'react-native-svg'; import { IconProps } from '../utils/base'; -export const GoForward: React.FC = ({ fill, height = 24, width = 24 }) => { - const { - theme: { - colors: { black }, - }, - } = useTheme(); - +export const GoForward: React.FC = ({ height = 20, width = 20, ...rest }) => { return ( - + + + ); }; diff --git a/examples/SampleApp/src/icons/LeaveGroup.tsx b/examples/SampleApp/src/icons/LeaveGroup.tsx new file mode 100644 index 0000000000..be98429480 --- /dev/null +++ b/examples/SampleApp/src/icons/LeaveGroup.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import Svg, { Path } from 'react-native-svg'; + +import { IconProps } from '../utils/base'; + +export const LeaveGroup: React.FC = ({ height = 20, width = 20, ...rest }) => ( + + + +); diff --git a/examples/SampleApp/src/icons/Mute.tsx b/examples/SampleApp/src/icons/Mute.tsx index 0e8bfc9a40..9edc9ef7bc 100644 --- a/examples/SampleApp/src/icons/Mute.tsx +++ b/examples/SampleApp/src/icons/Mute.tsx @@ -1,23 +1,39 @@ import React from 'react'; -import Svg, { Path } from 'react-native-svg'; -import { useTheme } from 'stream-chat-react-native'; +import Svg, { Path, Mask } from 'react-native-svg'; import { IconProps } from '../utils/base'; -export const Mute: React.FC = ({ height, width }) => { - const { - theme: { - colors: { grey }, - }, - } = useTheme(); - +export const Mute: React.FC = ({ height = 20, width = 20, ...rest }) => { return ( - + + + + + + + ); diff --git a/examples/SampleApp/src/icons/Picture.tsx b/examples/SampleApp/src/icons/Picture.tsx index e2b8ad404b..01e3c5287e 100644 --- a/examples/SampleApp/src/icons/Picture.tsx +++ b/examples/SampleApp/src/icons/Picture.tsx @@ -1,21 +1,16 @@ import React from 'react'; import Svg, { G, Path } from 'react-native-svg'; -import { useTheme } from 'stream-chat-react-native'; import { IconProps } from '../utils/base'; -export const Picture: React.FC = ({ fill, height = 24, scale = 1, width = 24 }) => { - const { - theme: { - colors: { black }, - }, - } = useTheme(); - +export const Picture: React.FC = ({ height = 20, width = 20, ...rest }) => { return ( - - - - + + + ); diff --git a/examples/SampleApp/src/icons/UserSearch.tsx b/examples/SampleApp/src/icons/UserSearch.tsx new file mode 100644 index 0000000000..8f58f93704 --- /dev/null +++ b/examples/SampleApp/src/icons/UserSearch.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import Svg, { Path } from 'react-native-svg'; + +import { IconProps } from '../utils/base'; + +export const UserSearch: React.FC = ({ height = 20, width = 20, ...rest }) => { + return ( + + + + ); +}; diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 9f5c174031..eac243cfb0 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -115,10 +115,7 @@ const ChannelHeader: React.FC = ({ channel }) => { }; // Either provide channel or channelId. -export const ChannelScreen: React.FC = ({ - navigation, - route, -}) => { +export const ChannelScreen: React.FC = ({ navigation, route }) => { const { channel: channelFromProp, channelId, messageId } = route.params; const { chatClient, @@ -216,7 +213,9 @@ export const ChannelScreen: React.FC = ({ if (stackRoute.name !== 'ThreadScreen') { return false; } - const routeParams = stackRoute.params as StackNavigatorParamList['ThreadScreen'] | undefined; + const routeParams = stackRoute.params as + | StackNavigatorParamList['ThreadScreen'] + | undefined; const routeThreadId = (routeParams?.thread as LocalMessage)?.id ?? (routeParams?.thread as ThreadType)?.thread?.id; @@ -283,10 +282,7 @@ export const ChannelScreen: React.FC = ({ thread={selectedThread} maximumMessageLimit={messageListPruning} > - + {messageListImplementation === 'flashlist' ? ( diff --git a/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx b/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx index ea9592f839..3d874c5d80 100644 --- a/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx +++ b/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx @@ -1,125 +1,37 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { - ScrollView, - StyleSheet, - Switch, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; -import { RouteProp, useNavigation } from '@react-navigation/native'; +import React, { useCallback, useMemo, useState } from 'react'; +import { 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 { + ChannelAvatar, useChannelPreviewDisplayName, useOverlayContext, useTheme, - UserAvatar, + Pin, } from 'stream-chat-react-native'; -import { RoundButton } from '../components/RoundButton'; +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 { useAppOverlayContext } from '../context/AppOverlayContext'; -import { useBottomSheetOverlayContext } from '../context/BottomSheetOverlayContext'; -import { useUserInfoOverlayContext } from '../context/UserInfoOverlayContext'; -import { useChannelMembersStatus } from '../hooks/useChannelMembersStatus'; -import { AddUser } from '../icons/AddUser'; -import { Check } from '../icons/Check'; -import { CircleClose } from '../icons/CircleClose'; -import { DownArrow } from '../icons/DownArrow'; 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 { RemoveUser } from '../icons/RemoveUser'; -import { getUserActivityStatus } from '../utils/getUserActivityStatus'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import type { Channel, UserResponse } from 'stream-chat'; - +import type { ChannelMemberResponse, UserResponse } from 'stream-chat'; import type { StackNavigatorParamList } from '../types'; -import { Pin } from '../icons/Pin'; -import { NewGroupIcon } from '../icons/NewGroupIcon'; -const styles = StyleSheet.create({ - actionContainer: { - alignItems: 'center', - borderBottomWidth: 1, - flexDirection: 'row', - justifyContent: 'space-between', - padding: 16, - }, - actionLabelContainer: { - alignItems: 'center', - flexDirection: 'row', - }, - changeNameContainer: { - alignItems: 'center', - borderBottomWidth: 1, - flexDirection: 'row', - padding: 16, - }, - changeNameInputBox: { - flex: 1, - fontSize: 14, - fontWeight: '600', - includeFontPadding: false, // for android vertical text centering - padding: 0, // removal of default text input padding on android - paddingLeft: 16, - paddingTop: 0, // removal of iOS top padding for weird centering - textAlignVertical: 'center', // for android vertical text centering - }, - changeNameInputContainer: { - alignItems: 'center', - flex: 1, - flexDirection: 'row', - }, - container: { - flex: 1, - }, - itemText: { - fontSize: 14, - paddingLeft: 16, - }, - loadMoreButton: { - alignItems: 'center', - borderBottomWidth: 1, - flexDirection: 'row', - paddingHorizontal: 16, - paddingVertical: 20, - width: '100%', - }, - loadMoreText: { - fontSize: 14, - paddingLeft: 20, - }, - memberContainer: { - alignItems: 'center', - borderBottomWidth: 1, - flexDirection: 'row', - justifyContent: 'space-between', - paddingHorizontal: 8, - paddingVertical: 12, - width: '100%', - }, - memberDetails: { - paddingLeft: 8, - }, - memberName: { - fontSize: 14, - fontWeight: '700', - paddingBottom: 1, - }, - memberRow: { - alignItems: 'center', - flexDirection: 'row', - }, - row: { flexDirection: 'row' }, - spacer: { - height: 8, - }, -}); +const MAX_VISIBLE_MEMBERS = 5; type GroupChannelDetailsRouteProp = RouteProp; @@ -132,408 +44,354 @@ type GroupChannelDetailsScreenNavigationProp = NativeStackNavigationProp< 'GroupChannelDetailsScreen' >; -const Spacer = () => { - const { - theme: { - colors: { grey_gainsboro }, - }, - } = useTheme(); - return ( - - ); -}; - export const GroupChannelDetailsScreen: React.FC = ({ route: { params: { channel }, }, }) => { const { chatClient } = useAppContext(); - const { setOverlay: setAppOverlay } = useAppOverlayContext(); - const { setData: setBottomSheetOverlayData } = useBottomSheetOverlayContext(); - const { setData: setUserInfoOverlayData } = useUserInfoOverlayContext(); const navigation = useNavigation(); const { setOverlay } = useOverlayContext(); const { - theme: { - colors: { accent_blue, accent_green, black, grey, white, white_smoke }, - semantics, - }, + theme: { semantics }, } = useTheme(); - const textInputRef = useRef(null); const [muted, setMuted] = useState( chatClient?.mutedChannels.some((mute) => mute.channel?.id === channel?.id), ); - const [groupName, setGroupName] = useState(channel.data?.name); - const allMembers = Object.values(channel.state.members); - const [members, setMembers] = useState(allMembers.slice(0, 3)); - const [textInputFocused, setTextInputFocused] = useState(false); + 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 membersStatus = useChannelMembersStatus(channel); 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 allMembersLength = allMembers.length; - useEffect(() => { - setMembers(allMembers.slice(0, 3)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allMembersLength]); + const memberStatusText = useMemo(() => { + const parts = [`${memberCount} members`]; + if (onlineCount > 0) { + parts.push(`${onlineCount} online`); + } + return parts.join(' ยท '); + }, [memberCount, onlineCount]); - if (!channel) { - return null; - } + 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); - /** - * Opens confirmation sheet for leaving the group - */ - const openLeaveGroupConfirmationSheet = () => { + const leaveGroup = useCallback(async () => { if (chatClient?.user?.id) { - setBottomSheetOverlayData({ - confirmText: 'LEAVE', - onConfirm: leaveGroup, - subtext: `Are you sure you want to leave the group ${groupName || ''}?`, - title: 'Leave group', - }); - setAppOverlay('confirmation'); + await channel.removeMembers([chatClient.user.id]); } - }; + setOverlay('none'); + navigation.reset({ + index: 0, + routes: [{ name: 'MessagingScreen' }], + }); + }, [channel, chatClient?.user?.id, navigation, setOverlay]); - /** - * Cancels the confirmation sheet. - */ - const openAddMembersSheet = () => { - if (chatClient?.user?.id) { - setBottomSheetOverlayData({ - channel, - }); - setAppOverlay('addMembers'); + const openLeaveGroupConfirmationSheet = useCallback(() => { + if (!chatClient?.user?.id) { + return; } - }; + setConfirmationVisible(true); + }, [chatClient?.user?.id]); - /** - * Leave the group/channel - */ - const leaveGroup = async () => { - if (chatClient?.user?.id) { - await channel.removeMembers([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(); } - setAppOverlay('none'); - setOverlay('none'); + setMuted((prev) => !prev); + }, [channel, muted]); - navigation.reset({ - index: 0, - routes: [ - { - name: 'MessagingScreen', - }, - ], - }); - }; + 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], + ); + + if (!channel) { + return null; + } + + const chevronRight = ; return ( - - - channelCreatorId === chatClient?.user?.id ? ( - - - - ) : null - } - subtitleText={membersStatus} - titleText={displayName} - /> - - {members.map((member) => { - if (!member.user?.id) { - return null; - } - - return ( - { - if (member.user?.id !== chatClient?.user?.id) { - setUserInfoOverlayData({ - channel, - member, - navigation, - }); - setAppOverlay('userInfo'); - } - }} - style={[ - styles.memberContainer, - { - borderBottomColor: semantics.borderCoreDefault, - }, - ]} - > - - - - {member.user?.name} - {getUserActivityStatus(member.user)} - - - - {channelCreatorId === member.user?.id ? 'owner' : ''} - - - ); - })} - {allMembersLength !== members.length && ( - { - setMembers(Object.values(channel.state.members)); - }} - style={[ - styles.loadMoreButton, - { - borderBottomColor: semantics.borderCoreDefault, - }, - ]} - > - - - {`${allMembersLength - members.length} more`} + + rightContent} /> + + } + 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`} - - )} - - - <> - - - { - setTextInputFocused(false); - }} - onChangeText={setGroupName} - onFocus={() => { - setTextInputFocused(true); - }} - placeholder='Add a group name' - placeholderTextColor={grey} - ref={textInputRef} - style={[{ color: black }, styles.changeNameInputBox]} - value={groupName} - /> - - { - setGroupName(channel.data?.name); - if (textInputRef.current) { - textInputRef.current.blur(); - } - }} - style={{ - paddingRight: 8, - }} - > - - - { - await channel.update({ - ...channel.data, - name: groupName, - } as Parameters[0]); - if (textInputRef.current) { - textInputRef.current.blur(); - } - }} + {isCreator ? ( + - {!!groupName && } - - + + Add + + + ) : null} - - - - - Mute group - + + {visibleMembers.map((member) => { + if (!member.user?.id) { + return null; + } + return ( + handleMemberPress(member)} + /> + ); + })} + + {hasMoreMembers ? ( + + + + View all + + - + ) : null} + + + + } + label='Mute Group' + trailing={ { - if (muted) { - await channel.unmute(); - } else { - await channel.mute(); - } - - setMuted((previousState) => !previousState); - }} + onValueChange={handleMuteToggle} trackColor={{ - false: white_smoke, - true: accent_green, + false: semantics.controlToggleSwitchBg, + true: semantics.accentPrimary, }} - value={muted} + value={muted ?? false} /> - - - { - navigation.navigate('ChannelPinnedMessagesScreen', { - channel, - }); - }} - style={[ - styles.actionContainer, - { - borderBottomColor: semantics.borderCoreDefault, - }, - ]} - > - - - - Pinned Messages - - - - - - - { - navigation.navigate('ChannelImagesScreen', { - channel, - }); - }} - style={[ - styles.actionContainer, - { - borderBottomColor: semantics.borderCoreDefault, - }, - ]} - > - - - - Photos and Videos - - - - - - - { - navigation.navigate('ChannelFilesScreen', { - channel, - }); - }} - style={[ - styles.actionContainer, - { - borderBottomColor: semantics.borderCoreDefault, - }, - ]} - > - - - - Files - - - - - - - + } + label='Leave Group' + destructive onPress={openLeaveGroupConfirmationSheet} - style={[ - styles.actionContainer, - { - borderBottomColor: semantics.borderCoreDefault, - }, - ]} - > - - - - Leave Group - - - - + /> + + + + + + ); }; + +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, + }, + 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 11583326aa..446544d6e9 100644 --- a/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx +++ b/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx @@ -1,97 +1,23 @@ -import React, { useState } from 'react'; -import { Image, ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; -import { Delete, useTheme } from 'stream-chat-react-native'; +import React, { useCallback, useState } from 'react'; +import { ScrollView, StyleSheet, Switch } from 'react-native'; +import { ChannelAvatar, Delete, useTheme, Pin, CircleBan } from 'stream-chat-react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +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 { useAppOverlayContext } from '../context/AppOverlayContext'; -import { useBottomSheetOverlayContext } from '../context/BottomSheetOverlayContext'; -import { Contacts } from '../icons/Contacts'; import { File } from '../icons/File'; -import { GoBack } from '../icons/GoBack'; import { GoForward } from '../icons/GoForward'; import { Mute } from '../icons/Mute'; -import { Notification } from '../icons/Notification'; import { Picture } from '../icons/Picture'; -import { Pin } from '../icons/Pin'; import { getUserActivityStatus } from '../utils/getUserActivityStatus'; import type { RouteProp } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; - import type { StackNavigatorParamList } from '../types'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -const styles = StyleSheet.create({ - actionContainer: { - borderBottomWidth: 1, - flexDirection: 'row', - justifyContent: 'space-between', - padding: 20, - }, - actionLabelContainer: { - alignItems: 'center', - flexDirection: 'row', - }, - avatar: { - borderRadius: 36, - height: 72, - width: 72, - }, - backButton: { - left: 0, - paddingLeft: 16, - position: 'absolute', - top: 0, - }, - container: { - flex: 1, - }, - contentContainer: { - flexGrow: 1, - }, - displayName: { - fontSize: 16, - fontWeight: '600', - paddingTop: 16, - }, - itemText: { - fontSize: 14, - paddingLeft: 16, - }, - onlineIndicator: { - borderRadius: 4, - height: 8, - width: 8, - }, - onlineStatus: { - fontSize: 12, - paddingLeft: 8, - }, - onlineStatusContainer: { - alignItems: 'center', - flexDirection: 'row', - paddingBottom: 16, - paddingTop: 8, - }, - spacer: { - height: 8, - }, - userInfoContainer: { - alignItems: 'center', - justifyContent: 'center', - paddingTop: 20, - }, - userName: { - fontSize: 14, - }, - userNameContainer: { - alignSelf: 'stretch', - borderTopWidth: 1, - flexDirection: 'row', - justifyContent: 'space-between', - padding: 20, - }, -}); type OneOnOneChannelDetailScreenRouteProp = RouteProp< StackNavigatorParamList, @@ -108,24 +34,6 @@ type Props = { route: OneOnOneChannelDetailScreenRouteProp; }; -const Spacer = () => { - const { - theme: { - colors: { grey_gainsboro }, - }, - } = useTheme(); - return ( - - ); -}; - export const OneOnOneChannelDetailScreen: React.FC = ({ navigation, route: { @@ -133,14 +41,12 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ }, }) => { const { - theme: { - colors: { accent_green, accent_red, black, grey, white, white_smoke }, - semantics, - }, + theme: { semantics }, } = useTheme(); const { chatClient } = useAppContext(); - const { setOverlay } = useAppOverlayContext(); - const { setData } = useBottomSheetOverlayContext(); + + 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, @@ -149,345 +55,180 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ const user = member?.user; const [muted, setMuted] = useState( chatClient?.mutedUsers && - chatClient?.mutedUsers?.findIndex((mutedUser) => mutedUser.target.id === user?.id) > -1, - ); - const [notificationsEnabled, setNotificationsEnabled] = useState( - chatClient?.mutedChannels && - chatClient.mutedChannels.findIndex( - (mutedChannel) => mutedChannel.channel?.id === channel.id, - ) > -1, + chatClient.mutedUsers.findIndex((mutedUser) => mutedUser.target.id === user?.id) > -1, ); - /** - * Opens confirmation sheet for deleting the conversation - */ - const openDeleteConversationConfirmationSheet = () => { + 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); + 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; } - setData({ - confirmText: 'DELETE', - onConfirm: deleteConversation, - subtext: 'Are you sure you want to delete this conversation?', - title: 'Delete Conversation', - }); - setOverlay('confirmation'); - }; + setConfirmationVisible(true); + }, [chatClient?.user?.id]); + + const openBlockUserConfirmationSheet = useCallback(() => { + if (!user?.id) { + return; + } + setBlockUserConfirmationVisible(true); + }, [user?.id]); - /** - * Leave the group/channel - */ - const deleteConversation = async () => { - await channel.delete(); - setOverlay('none'); - navigation.reset({ - index: 0, - routes: [ - { - name: 'MessagingScreen', - }, - ], - }); - }; + 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 ( - - - - - - {user.name} - - - {user.online && ( - - )} - - {user?.online ? 'Online' : getUserActivityStatus(user)} - - - - - @{user.id} - - - {user.name} - - - { - navigation.goBack(); - }} - style={styles.backButton} - > - - - - - - - - - Notifications - - - - { - if (notificationsEnabled) { - await channel.unmute(); - } else { - await channel.mute(); - } - setNotificationsEnabled((previousState) => !previousState); - }} - trackColor={{ - false: white_smoke, - true: accent_green, - }} - value={notificationsEnabled} - /> - - - - - - - Mute user - - - - { - if (muted) { - const r = await chatClient?.unmuteUser(user.id); - console.warn(r); - } else { - const r = await chatClient?.muteUser(user.id); - console.warn(r); - } - setMuted((previousState) => !previousState); - }} - trackColor={{ - false: white_smoke, - true: accent_green, - }} - value={muted} - /> - - - { - navigation.navigate('ChannelPinnedMessagesScreen', { - channel, - }); - }} - style={[ - styles.actionContainer, - { - borderBottomColor: semantics.borderCoreDefault, - }, - ]} - > - - - - Pinned Messages - - - - - - - { - navigation.navigate('ChannelImagesScreen', { - channel, - }); - }} - style={[ - styles.actionContainer, - { - borderBottomColor: semantics.borderCoreDefault, - }, - ]} - > - - - - Photos and Videos - - - - - - - { - navigation.navigate('ChannelFilesScreen', { - channel, - }); - }} - style={[ - styles.actionContainer, - { - borderBottomColor: semantics.borderCoreDefault, - }, - ]} - > - - - - Files - - - - - - - { - navigation.navigate('SharedGroupsScreen', { - user, - }); - }} - style={[ - styles.actionContainer, - { - borderBottomColor: semantics.borderCoreDefault, - }, - ]} - > - - - - Shared Groups - - - - - - - - - - - - Delete contact - - - + + + + } + 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} + /> + + + ); }; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollContent: { + gap: 16, + paddingBottom: 40, + paddingHorizontal: 16, + paddingTop: 32, + }, +}); diff --git a/examples/SampleApp/src/utils/messageActions.tsx b/examples/SampleApp/src/utils/messageActions.tsx index 0155f459b1..c791acd42c 100644 --- a/examples/SampleApp/src/utils/messageActions.tsx +++ b/examples/SampleApp/src/utils/messageActions.tsx @@ -24,7 +24,7 @@ export function channelMessageActions({ handleMessageInfo: (message: LocalMessage) => void; semantics: Theme['semantics']; }) { - const { dismissOverlay, error, /*deleteForMeMessage*/ } = params; + const { dismissOverlay, error /*deleteForMeMessage*/ } = params; const actions = messageActions(params); // We cannot use the useMessageReminder hook here because it is a hook. @@ -79,8 +79,8 @@ export function channelMessageActions({ messageId: params.message.id, remind_at: new Date(new Date().getTime() + offsetMs).toISOString(), }) - .catch((error) => { - console.error('Error creating reminder:', error); + .catch((_error) => { + console.error('Error creating reminder:', _error); }); }, style: 'default', diff --git a/package/src/icons/index.ts b/package/src/icons/index.ts index a3ee131885..425c53c382 100644 --- a/package/src/icons/index.ts +++ b/package/src/icons/index.ts @@ -8,6 +8,7 @@ export * from './Camera'; export * from './Check'; export * from './CheckAll'; export * from './CircleStop'; +export * from './CircleBan'; export * from './Close'; export * from './Copy'; export * from './CurveLineLeftUp';