diff --git a/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt b/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt index c811c58912..b5478998f5 100644 --- a/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt +++ b/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt @@ -1,12 +1,8 @@ package io.getstream.reactnative.sampleapp +import android.graphics.Color import android.os.Build import android.os.Bundle -import android.view.View -import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled @@ -17,28 +13,12 @@ class MainActivity : ReactActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(null) - if (Build.VERSION.SDK_INT >= 35) { - val rootView = findViewById(android.R.id.content) + window.navigationBarColor = Color.TRANSPARENT + window.statusBarColor = Color.TRANSPARENT - val initial = Insets.of( - rootView.paddingLeft, - rootView.paddingTop, - rootView.paddingRight, - rootView.paddingBottom - ) - - ViewCompat.setOnApplyWindowInsetsListener(rootView) { v, insets -> - val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) - - v.updatePadding( - left = initial.left, - top = initial.top, - right = initial.right, - bottom = initial.bottom + ime.bottom - ) - - insets - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + window.isStatusBarContrastEnforced = false } } diff --git a/examples/SampleApp/android/app/src/main/res/values/styles.xml b/examples/SampleApp/android/app/src/main/res/values/styles.xml index 24bc061368..a56de4d292 100644 --- a/examples/SampleApp/android/app/src/main/res/values/styles.xml +++ b/examples/SampleApp/android/app/src/main/res/values/styles.xml @@ -2,7 +2,9 @@ diff --git a/examples/SampleApp/src/components/ChannelDetailProfileSection.tsx b/examples/SampleApp/src/components/ChannelDetailProfileSection.tsx index 0d224cf3fd..f98d6ed8c5 100644 --- a/examples/SampleApp/src/components/ChannelDetailProfileSection.tsx +++ b/examples/SampleApp/src/components/ChannelDetailProfileSection.tsx @@ -1,15 +1,17 @@ import React, { useMemo } from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import { useTheme } from 'stream-chat-react-native'; + +import { ChannelPreviewMutedStatus, useTheme } from 'stream-chat-react-native'; type ChannelDetailProfileSectionProps = { avatar: React.ReactNode; + muted?: boolean; subtitle: string; title: string; }; export const ChannelDetailProfileSection = React.memo( - ({ avatar, subtitle, title }: ChannelDetailProfileSectionProps) => { + ({ avatar, muted, subtitle, title }: ChannelDetailProfileSectionProps) => { const { theme: { semantics }, } = useTheme(); @@ -19,9 +21,12 @@ export const ChannelDetailProfileSection = React.memo( {avatar} - - {title} - + + + {title} + + {muted ? : null} + {subtitle ? ( {subtitle} @@ -49,8 +54,16 @@ const useStyles = () => gap: 8, width: '100%', }, + titleRow: { + alignItems: 'center', + flexDirection: 'row', + gap: 4, + justifyContent: 'center', + maxWidth: '100%', + }, title: { fontSize: 22, + flexShrink: 1, fontWeight: '600', lineHeight: 24, textAlign: 'center', diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 16a13f01b4..771d4d0e30 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -273,7 +273,7 @@ export const ChannelScreen: React.FC = ({ navigation, route messageInputFloating={messageInputFloating} onPressMessage={onPressMessage} initialScrollToFirstUnreadMessage - keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : -300} + keyboardVerticalOffset={0} messageActions={messageActions} MessageLocation={MessageLocation} messageId={messageId} diff --git a/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx b/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx index 3d874c5d80..38f7bd31f5 100644 --- a/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx +++ b/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx @@ -1,10 +1,17 @@ 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 type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import type { ChannelMemberResponse, UserResponse } from 'stream-chat'; + import { ChannelAvatar, useChannelPreviewDisplayName, + useIsChannelMuted, useOverlayContext, useTheme, Pin, @@ -26,9 +33,6 @@ import { GoForward } from '../icons/GoForward'; import { LeaveGroup } from '../icons/LeaveGroup'; import { Mute } from '../icons/Mute'; import { Picture } from '../icons/Picture'; - -import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import type { ChannelMemberResponse, UserResponse } from 'stream-chat'; import type { StackNavigatorParamList } from '../types'; const MAX_VISIBLE_MEMBERS = 5; @@ -55,6 +59,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ const { theme: { semantics }, } = useTheme(); + const { muted: channelMuted } = useIsChannelMuted(channel); const [muted, setMuted] = useState( chatClient?.mutedChannels.some((mute) => mute.channel?.id === channel?.id), @@ -197,6 +202,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ } + muted={channelMuted} title={displayName} subtitle={memberStatusText} /> @@ -317,11 +323,7 @@ export const GroupChannelDetailsScreen: React.FC = ({ onClose={closeContactDetail} visible={selectedMember !== null} /> - + ); }; diff --git a/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx b/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx index 8b34499f14..5bad31912f 100644 --- a/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx +++ b/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Platform, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; import { useFocusEffect } from '@react-navigation/native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { @@ -204,9 +204,20 @@ export const NewDirectMessagingScreen: React.FC = initChannel(); }, [chatClient, selectedUserIds, selectedUsersLength]); + const onBackPress = useCallback(() => { + reset(); + + if (!navigation.canGoBack()) { + navigation.reset({ index: 0, routes: [{ name: 'MessagingScreen' }] }); + return; + } + + navigation.goBack(); + }, [navigation, reset]); + const renderUserSearch = ({ inSafeArea }: { inSafeArea: boolean }) => ( - + { @@ -341,7 +352,7 @@ export const NewDirectMessagingScreen: React.FC = channel={currentChannel.current} EmptyStateIndicator={EmptyMessagesIndicator} enforceUniqueReaction - keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : -300} + keyboardVerticalOffset={0} onChangeText={setMessageInputText} overrideOwnCapabilities={{ sendMessage: true }} SendButton={NewDirectMessagingSendButton} diff --git a/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx b/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx index 40d9f400e0..1eca6dfc54 100644 --- a/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx +++ b/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { FlatList, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native'; import { Search, useTheme } from 'stream-chat-react-native'; @@ -79,6 +79,17 @@ export const NewGroupChannelAddMemberScreen: React.FC = ({ navigation }) const { onChangeSearchText, onFocusInput, removeUser, reset, searchText, selectedUsers } = useUserSearchContext(); + const onBackPress = useCallback(() => { + reset(); + + if (!navigation.canGoBack()) { + navigation.reset({ index: 0, routes: [{ name: 'MessagingScreen' }] }); + return; + } + + navigation.goBack(); + }, [navigation, reset]); + const onRightArrowPress = () => { if (selectedUsers.length === 0) { return; @@ -93,7 +104,7 @@ export const NewGroupChannelAddMemberScreen: React.FC = ({ navigation }) return ( ( diff --git a/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx b/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx index 446544d6e9..a629d81849 100644 --- a/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx +++ b/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx @@ -1,8 +1,19 @@ 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 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 { ChannelDetailProfileSection } from '../components/ChannelDetailProfileSection'; import { ConfirmationBottomSheet } from '../components/ConfirmationBottomSheet'; import { ListItem } from '../components/ListItem'; @@ -13,11 +24,8 @@ import { File } from '../icons/File'; import { GoForward } from '../icons/GoForward'; import { Mute } from '../icons/Mute'; import { Picture } from '../icons/Picture'; -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 { getUserActivityStatus } from '../utils/getUserActivityStatus'; type OneOnOneChannelDetailScreenRouteProp = RouteProp< StackNavigatorParamList, @@ -44,6 +52,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ theme: { semantics }, } = useTheme(); const { chatClient } = useAppContext(); + const userMuted = useChannelMuteActive(channel); const [confirmationVisible, setConfirmationVisible] = useState(false); const [blockUserConfirmationVisible, setBlockUserConfirmationVisible] = useState(false); @@ -141,6 +150,7 @@ export const OneOnOneChannelDetailScreen: React.FC = ({ } + muted={userMuted} title={user.name || user.id} subtitle={activityStatus} /> diff --git a/examples/SampleApp/src/screens/ThreadScreen.tsx b/examples/SampleApp/src/screens/ThreadScreen.tsx index 6fe9062294..14ba9da6a2 100644 --- a/examples/SampleApp/src/screens/ThreadScreen.tsx +++ b/examples/SampleApp/src/screens/ThreadScreen.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { AlsoSentToChannelHeaderPressPayload, @@ -149,11 +149,11 @@ export const ThreadScreen: React.FC = ({ return ( ) : ( { /> {Platform.OS === 'android' ? ( diff --git a/package/src/components/ChannelList/hooks/__tests__/useChannelActionItems.test.tsx b/package/src/components/ChannelList/hooks/__tests__/useChannelActionItems.test.tsx index 2cb83b3d6a..1ce3baf268 100644 --- a/package/src/components/ChannelList/hooks/__tests__/useChannelActionItems.test.tsx +++ b/package/src/components/ChannelList/hooks/__tests__/useChannelActionItems.test.tsx @@ -110,7 +110,7 @@ describe('useChannelActionItems', () => { 'destructive', ]); expect(result.current.map((item) => item.placement)).toEqual([ - 'both', + 'swipe', 'both', 'sheet', 'sheet', @@ -266,7 +266,7 @@ describe('getChannelActionItems', () => { expect(actionItems[0].action).toBe(channelActions.unmuteChannel); expect(actionItems[0].label).toBe('Unmute Group'); - expect(actionItems[0].placement).toBe('both'); + expect(actionItems[0].placement).toBe('swipe'); expect(actionItems[1].action).toBe(channelActions.unarchive); expect(actionItems[1].label).toBe('Unarchive Group'); diff --git a/package/src/components/ChannelList/hooks/useChannelActionItems.tsx b/package/src/components/ChannelList/hooks/useChannelActionItems.tsx index d2fe6342cd..6006396df8 100644 --- a/package/src/components/ChannelList/hooks/useChannelActionItems.tsx +++ b/package/src/components/ChannelList/hooks/useChannelActionItems.tsx @@ -112,7 +112,7 @@ export const buildDefaultChannelActionItems: BuildDefaultChannelActionItems = ( : muteActive ? t('Unmute Group') : t('Mute Group'), - placement: isDirectChat ? 'sheet' : 'both', + placement: isDirectChat ? 'sheet' : 'swipe', type: 'standard', }, ]; diff --git a/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx b/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx index c4e1b6c3af..670fd55393 100644 --- a/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx +++ b/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx @@ -6,7 +6,9 @@ import { Pressable } from 'react-native-gesture-handler'; import { Channel } from 'stream-chat'; +import { ChannelPreviewMutedStatus } from './ChannelPreviewMutedStatus'; import { ChannelPreviewTitle } from './ChannelPreviewTitle'; +import { useIsChannelMuted } from './hooks/useIsChannelMuted'; import { useBottomSheetContext, useTheme, useTranslationContext } from '../../contexts'; import { useSwipeRegistryContext } from '../../contexts/swipeableContext/SwipeRegistryContext'; @@ -14,6 +16,7 @@ import { useStableCallback } from '../../hooks'; import { primitives } from '../../theme'; import { ChannelActionItem } from '../ChannelList/hooks/useChannelActionItems'; import { useChannelMembersState } from '../ChannelList/hooks/useChannelMembersState'; +import { useChannelMuteActive } from '../ChannelList/hooks/useChannelMuteActive'; import { useChannelOnlineMemberCount } from '../ChannelList/hooks/useChannelOnlineMemberCount'; import { useIsDirectChat } from '../ChannelList/hooks/useIsDirectChat'; import { ChannelAvatar } from '../ui'; @@ -35,6 +38,9 @@ export const ChannelDetailsHeader = ({ channel }: ChannelDetailsHeaderProps) => const memberCount = useMemo(() => Object.keys(members).length, [members]); const onlineCount = useChannelOnlineMemberCount(channel); const isDirectChat = useIsDirectChat(channel); + const { muted: channelMuted } = useIsChannelMuted(channel); + const directChatUserMuted = useChannelMuteActive(channel); + const muted = isDirectChat ? directChatUserMuted : channelMuted; const displayedMemberCount = memberCount > 9 ? '9+' : `${memberCount}`; const displayedOnlineCount = onlineCount > 9 ? '9+' : `${onlineCount}`; const membersAndOnlineLabel = useMemo( @@ -51,7 +57,10 @@ export const ChannelDetailsHeader = ({ channel }: ChannelDetailsHeaderProps) => - + + + {muted ? : null} + {isDirectChat ? (onlineCount === 1 ? t('Online') : t('Offline')) : membersAndOnlineLabel} @@ -145,6 +154,11 @@ const useStyles = () => { gap: primitives.spacingXxs, ...header.metaContainer, }, + titleContainer: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXxs, + }, itemContainer: { flexDirection: 'row', alignItems: 'center', diff --git a/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx b/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx index 35d043cfa2..98509ee6c9 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx @@ -76,6 +76,7 @@ export const ChannelPreviewMessage = (props: ChannelPreviewMessageProps) => { const isFailedMessage = lastMessage?.status === MessageStatusTypes.FAILED || lastMessage?.type === 'error'; + const showMessageDeliveryStatus = !isMessageDeleted; if (usersTyping.length > 0) { return ; @@ -120,14 +121,18 @@ export const ChannelPreviewMessage = (props: ChannelPreviewMessageProps) => { if (channel.data?.name || membersWithoutSelf.length > 1) { return ( - + {showMessageDeliveryStatus ? ( + + ) : null} ); } else { return ( - + {showMessageDeliveryStatus ? ( + + ) : null} ); diff --git a/package/src/components/ChannelPreview/ChannelSwipableWrapper.tsx b/package/src/components/ChannelPreview/ChannelSwipableWrapper.tsx index ef36ceb737..63a0de0b3f 100644 --- a/package/src/components/ChannelPreview/ChannelSwipableWrapper.tsx +++ b/package/src/components/ChannelPreview/ChannelSwipableWrapper.tsx @@ -7,15 +7,14 @@ import { Channel } from 'stream-chat'; import { ChannelDetailsBottomSheet as DefaultChannelDetailsBottomSheet } from './ChannelDetailsBottomSheet'; import type { ChannelDetailsBottomSheetProps } from './ChannelDetailsBottomSheet'; +import { useIsChannelMuted } from './hooks/useIsChannelMuted'; import { useTheme } from '../../contexts'; import { useSwipeRegistryContext } from '../../contexts/swipeableContext/SwipeRegistryContext'; -import { Archive, MenuPointHorizontal, Mute, Sound } from '../../icons'; +import { MenuPointHorizontal, Mute, Sound } from '../../icons'; import { GetChannelActionItems } from '../ChannelList/hooks/useChannelActionItems'; import { useChannelActionItems } from '../ChannelList/hooks/useChannelActionItems'; -import { useChannelActionItemsById } from '../ChannelList/hooks/useChannelActionItemsById'; -import { useChannelMuteActive } from '../ChannelList/hooks/useChannelMuteActive'; -import { useIsDirectChat } from '../ChannelList/hooks/useIsDirectChat'; +import { useChannelActions } from '../ChannelList/hooks/useChannelActions'; import { BottomSheetModal, RightActions, @@ -44,8 +43,12 @@ export const ChannelSwipableWrapper = ({ swipableProps?: SwipableWrapperProps['swipableProps']; }>) => { const [channelDetailSheetOpen, setChannelDetailSheetOpen] = useState(false); - const channelActionsById = useChannelActionItemsById({ channel, getChannelActionItems }); + const { muteChannel, unmuteChannel } = useChannelActions(channel); const channelActionItems = useChannelActionItems({ channel, getChannelActionItems }); + const sheetItems = useMemo( + () => channelActionItems.filter((item) => item.placement !== 'swipe'), + [channelActionItems], + ); const swipableRegistry = useSwipeRegistryContext(); const { @@ -53,19 +56,17 @@ export const ChannelSwipableWrapper = ({ } = useTheme(); const styles = useStyles(); - const isDirectChannel = useIsDirectChat(channel); - const muteActive = useChannelMuteActive(channel); + const channelMuteState = useIsChannelMuted(channel); + const channelMuteActive = channelMuteState.muted; const Icon = useCallback( () => - isDirectChannel ? ( - - ) : muteActive ? ( + channelMuteActive ? ( ) : ( ), - [isDirectChannel, muteActive, semantics.textOnAccent], + [channelMuteActive, semantics.textOnAccent], ); const swipableActions = useMemo(() => { @@ -78,29 +79,25 @@ export const ChannelSwipableWrapper = ({ }, ]; - const extraItem = isDirectChannel ? channelActionsById.archive : channelActionsById.mute; - - if (extraItem) { - const { id, action } = extraItem; - items.push({ - id, - action: () => { - action(); - swipableRegistry?.closeAll(); - }, - Content: Icon, - contentContainerStyle: [styles.contentContainerStyle, styles.standard], - }); - } + items.push({ + id: 'mute', + action: () => { + const action = channelMuteActive ? unmuteChannel : muteChannel; + action(); + swipableRegistry?.closeAll(); + }, + Content: Icon, + contentContainerStyle: [styles.contentContainerStyle, styles.standard], + }); return items; }, [ styles.contentContainerStyle, styles.elipsis, styles.standard, - isDirectChannel, - channelActionsById.mute, - channelActionsById.archive, + channelMuteActive, + muteChannel, + unmuteChannel, Icon, swipableRegistry, ]); @@ -127,7 +124,7 @@ export const ChannelSwipableWrapper = ({ visible={channelDetailSheetOpen} height={356} > - + ); diff --git a/package/src/components/ChannelPreview/__tests__/ChannelSwipableWrapper.test.tsx b/package/src/components/ChannelPreview/__tests__/ChannelSwipableWrapper.test.tsx new file mode 100644 index 0000000000..e3798b05e4 --- /dev/null +++ b/package/src/components/ChannelPreview/__tests__/ChannelSwipableWrapper.test.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { act, render } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import type { ChannelActionItem } from '../../ChannelList/hooks/useChannelActionItems'; +import * as ChannelActionItemsModule from '../../ChannelList/hooks/useChannelActionItems'; +import * as ChannelActionsModule from '../../ChannelList/hooks/useChannelActions'; +import { ChannelSwipableWrapper } from '../ChannelSwipableWrapper'; +import * as UseIsChannelMutedModule from '../hooks/useIsChannelMuted'; + +const rightActionsProbe = { + items: [] as Array<{ action: () => void; id: string }>, +}; + +const mockSwipableWrapper = jest.fn( + ({ + children, + swipableProps, + }: React.PropsWithChildren<{ + swipableProps?: { renderRightActions?: (...args: never[]) => void }; + }>) => { + const rightActions = swipableProps?.renderRightActions?.({} as never, {} as never); + return ( + <> + {children} + {rightActions} + + ); + }, +); + +jest.mock('../../../contexts', () => ({ + useTheme: () => ({ + theme: { + semantics: { + accentPrimary: '#00f', + backgroundCoreSurface: '#fff', + textOnAccent: '#000', + textPrimary: '#111', + }, + }, + }), +})); + +jest.mock('../../../contexts/swipeableContext/SwipeRegistryContext', () => ({ + useSwipeRegistryContext: () => ({ + closeAll: jest.fn(), + }), +})); + +jest.mock('../../UIComponents', () => ({ + BottomSheetModal: ({ children }: React.PropsWithChildren) => <>{children}, + RightActions: ({ items }: { items: Array<{ action: () => void; id: string }> }) => { + rightActionsProbe.items = items; + return null; + }, + SwipableWrapper: (...args: unknown[]) => mockSwipableWrapper(...args), +})); + +describe('ChannelSwipableWrapper', () => { + const channel = { cid: 'messaging:test-channel', id: 'test-channel' } as Channel; + + beforeEach(() => { + jest.clearAllMocks(); + rightActionsProbe.items = []; + }); + + it('uses channel mute for direct-channel swipe actions and keeps mute user in the sheet', () => { + const muteChannel = jest.fn(); + const unmuteChannel = jest.fn(); + const customBottomSheet = jest.fn(() => null); + const items: ChannelActionItem[] = [ + { + Icon: () => null, + action: jest.fn(), + id: 'mute', + label: 'Mute User', + placement: 'sheet', + type: 'standard', + }, + { + Icon: () => null, + action: jest.fn(), + id: 'archive', + label: 'Archive Chat', + placement: 'sheet', + type: 'standard', + }, + ]; + + jest.spyOn(ChannelActionsModule, 'useChannelActions').mockReturnValue({ + archive: jest.fn(), + blockUser: jest.fn(), + deleteChannel: jest.fn(), + leave: jest.fn(), + muteChannel, + muteUser: jest.fn(), + pin: jest.fn(), + unarchive: jest.fn(), + unblockUser: jest.fn(), + unmuteChannel, + unmuteUser: jest.fn(), + unpin: jest.fn(), + }); + jest.spyOn(ChannelActionItemsModule, 'useChannelActionItems').mockReturnValue(items); + jest.spyOn(UseIsChannelMutedModule, 'useIsChannelMuted').mockReturnValue({ + createdAt: null, + expiresAt: null, + muted: false, + }); + + render( + + child + , + ); + + expect(customBottomSheet).toHaveBeenCalledWith( + expect.objectContaining({ + channel, + items, + }), + undefined, + ); + expect(rightActionsProbe.items.map((item) => item.id)).toEqual(['openSheet', 'mute']); + + act(() => { + rightActionsProbe.items[1].action(); + }); + + expect(muteChannel).toHaveBeenCalledTimes(1); + expect(unmuteChannel).not.toHaveBeenCalled(); + }); + + it('removes mute group from the sheet while keeping mute as the quick swipe action', () => { + const muteChannel = jest.fn(); + const customBottomSheet = jest.fn(() => null); + const muteItem = { + Icon: () => null, + action: jest.fn(), + id: 'mute', + label: 'Mute Group', + placement: 'swipe', + type: 'standard', + } as const satisfies ChannelActionItem; + const archiveItem = { + Icon: () => null, + action: jest.fn(), + id: 'archive', + label: 'Archive Group', + placement: 'both', + type: 'standard', + } as const satisfies ChannelActionItem; + + jest.spyOn(ChannelActionsModule, 'useChannelActions').mockReturnValue({ + archive: jest.fn(), + blockUser: jest.fn(), + deleteChannel: jest.fn(), + leave: jest.fn(), + muteChannel, + muteUser: jest.fn(), + pin: jest.fn(), + unarchive: jest.fn(), + unblockUser: jest.fn(), + unmuteChannel: jest.fn(), + unmuteUser: jest.fn(), + unpin: jest.fn(), + }); + jest + .spyOn(ChannelActionItemsModule, 'useChannelActionItems') + .mockReturnValue([muteItem, archiveItem]); + jest.spyOn(UseIsChannelMutedModule, 'useIsChannelMuted').mockReturnValue({ + createdAt: null, + expiresAt: null, + muted: false, + }); + + render( + + child + , + ); + + expect(customBottomSheet).toHaveBeenCalledWith( + expect.objectContaining({ + channel, + items: [archiveItem], + }), + undefined, + ); + expect(rightActionsProbe.items.map((item) => item.id)).toEqual(['openSheet', 'mute']); + + act(() => { + rightActionsProbe.items[1].action(); + }); + + expect(muteChannel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx b/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx index 913761fdec..52f599ea16 100644 --- a/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx +++ b/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { + AccessibilityInfo, AppState, AppStateStatus, Dimensions, @@ -39,7 +40,7 @@ export class KeyboardCompatibleView extends React.Component< KeyboardAvoidingViewProps, 'behavior' | 'enabled' | 'keyboardVerticalOffset' > = { - behavior: Platform.OS === 'ios' ? 'padding' : 'position', + behavior: 'padding', enabled: true, keyboardVerticalOffset: Platform.OS === 'ios' ? 86.5 : -300, // default MessageComposer height }; @@ -62,35 +63,40 @@ export class KeyboardCompatibleView extends React.Component< this.viewRef = React.createRef(); } - _relativeKeyboardHeight(keyboardFrame: KeyboardMetrics) { + async _relativeKeyboardHeight(keyboardFrame: KeyboardMetrics): Promise { const frame = this._frame; - /** - * With iOS 14 & Reduce Motion > Prefer Cross-Fade Transitions enabled, the keyboard position - * height is reported differently (0 instead of Y position value) which caused the view to take full height - * of the screen. - */ - if (!frame || !keyboardFrame || keyboardFrame.screenY === 0) { + if (!frame || !keyboardFrame) { + return 0; + } + + if ( + Platform.OS === 'ios' && + keyboardFrame.screenY === 0 && + (await AccessibilityInfo.prefersCrossFadeTransitions()) + ) { return 0; } const keyboardY = keyboardFrame.screenY - (this.props.keyboardVerticalOffset ?? 0); - const relativeHeight = frame.y + frame.height - keyboardY; - /** - * When the StatusBar is translucent there is an issue - * where the relative keyboard height is returned incorrectly - * instead of 0 when closed. - */ + if (this.props.behavior === 'height') { + return Math.max(this.state.bottom + frame.y + frame.height - keyboardY, 0); + } + + // Calculate the displacement needed for the view such that it + // no longer overlaps with the keyboard + const relativeHeight = Math.max(frame.y + frame.height - keyboardY, 0); + if (Platform.OS === 'android') { const barHeights = Dimensions.get('screen').height - Dimensions.get('window').height; - if (relativeHeight <= Math.max(barHeights, StatusBar.currentHeight ?? 0)) { + const systemInsetFloor = Math.max(barHeights, StatusBar.currentHeight ?? 0); + + if (relativeHeight <= systemInsetFloor) { return 0; } } - // Calculate the displacement needed for the view such that it - // no longer overlaps with the keyboard - return Math.max(relativeHeight, 0); + return relativeHeight; } _onKeyboardChange: KeyboardEventListener = (event) => { @@ -98,7 +104,12 @@ export class KeyboardCompatibleView extends React.Component< this._updateBottomIfNecessary(); }; - _onLayout: (event: LayoutChangeEvent) => void = (event) => { + _onKeyboardHide: KeyboardEventListener = () => { + this._keyboardEvent = null; + this._updateBottomIfNecessary(); + }; + + _onLayout: (event: LayoutChangeEvent) => void = async (event) => { event.persist(); const oldFrame = this._frame; @@ -110,7 +121,7 @@ export class KeyboardCompatibleView extends React.Component< // update bottom height for the first time or when the height is changed if (!oldFrame || oldFrame.height !== this._frame.height) { - this._updateBottomIfNecessary(); + await this._updateBottomIfNecessary(); } if (this.props.onLayout) { @@ -127,14 +138,14 @@ export class KeyboardCompatibleView extends React.Component< } }; - _updateBottomIfNecessary = () => { + _updateBottomIfNecessary = async () => { if (this._keyboardEvent == null) { this._setBottom(0); return; } const { duration, easing, endCoordinates } = this._keyboardEvent; - const height = this._relativeKeyboardHeight(endCoordinates); + const height = await this._relativeKeyboardHeight(endCoordinates); if (this._bottom === height) { return; @@ -142,7 +153,8 @@ export class KeyboardCompatibleView extends React.Component< this._setBottom(height); - if (duration && easing) { + const enabled = this.props.enabled ?? true; + if (enabled && duration && easing) { LayoutAnimation.configureNext({ // We have to pass the duration equal to minimal accepted duration defined here: RCTLayoutAnimation.m duration: duration > 10 ? duration : 10, @@ -169,11 +181,12 @@ export class KeyboardCompatibleView extends React.Component< setKeyboardListeners = () => { if (Platform.OS === 'ios') { this._subscriptions = [ - Keyboard.addListener('keyboardWillChangeFrame', this._onKeyboardChange), + Keyboard.addListener('keyboardWillHide', this._onKeyboardHide), + Keyboard.addListener('keyboardWillShow', this._onKeyboardChange), ]; } else { this._subscriptions = [ - Keyboard.addListener('keyboardDidHide', this._onKeyboardChange), + Keyboard.addListener('keyboardDidHide', this._onKeyboardHide), Keyboard.addListener('keyboardDidShow', this._onKeyboardChange), ]; } @@ -218,6 +231,11 @@ export class KeyboardCompatibleView extends React.Component< } componentDidMount() { + if (!Keyboard.isVisible()) { + this._keyboardEvent = null; + this._setBottom(0); + } + this._appStateSubscription = AppState.addEventListener('change', this._handleAppStateChange); this.setKeyboardListeners(); } diff --git a/package/src/components/KeyboardCompatibleView/KeyboardControllerAvoidingView.tsx b/package/src/components/KeyboardCompatibleView/KeyboardControllerAvoidingView.tsx index b535c2680f..fd31080d70 100644 --- a/package/src/components/KeyboardCompatibleView/KeyboardControllerAvoidingView.tsx +++ b/package/src/components/KeyboardCompatibleView/KeyboardControllerAvoidingView.tsx @@ -2,7 +2,6 @@ import React, { useEffect } from 'react'; import { Keyboard, - Platform, KeyboardAvoidingViewProps as ReactNativeKeyboardAvoidingViewProps, } from 'react-native'; @@ -51,12 +50,7 @@ export const KeyboardCompatibleView = (props: KeyboardCompatibleViewProps) => { ); } - const compatibleBehavior = - behavior === 'translate-with-padding' - ? Platform.OS === 'ios' - ? 'padding' - : 'position' - : behavior; + const compatibleBehavior = behavior === 'translate-with-padding' ? 'padding' : behavior; return ( diff --git a/package/src/components/Message/utils/messageActions.ts b/package/src/components/Message/utils/messageActions.ts index 6abc2c1a08..7c98b2c155 100644 --- a/package/src/components/Message/utils/messageActions.ts +++ b/package/src/components/Message/utils/messageActions.ts @@ -90,7 +90,7 @@ export const messageActions = ({ actions.push(copyMessage); } - if (ownCapabilities.readEvents && !error && !isThreadMessage) { + if (ownCapabilities.readEvents && !error && !isThreadMessage && !isMyMessage) { actions.push(markUnread); } diff --git a/package/src/components/MessageMenu/MessageUserReactions.tsx b/package/src/components/MessageMenu/MessageUserReactions.tsx index f3f07625f6..1bb6728122 100644 --- a/package/src/components/MessageMenu/MessageUserReactions.tsx +++ b/package/src/components/MessageMenu/MessageUserReactions.tsx @@ -125,7 +125,9 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { const MessageUserReactionsItem = propMessageUserReactionsItem ?? contextMessageUserReactionsItem; const onSelectReaction = useStableCallback((reactionType: string) => { - setSelectedReaction(reactionType); + setSelectedReaction((currentReaction) => + currentReaction === reactionType ? undefined : reactionType, + ); }); useEffect(() => { diff --git a/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx b/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx index a36f64bc51..c7312dccee 100644 --- a/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx +++ b/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx @@ -100,14 +100,21 @@ describe('MessageUserReactions when the supportedReactions are defined', () => { expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-unselected'); }); - it('changes selected reaction when a reaction button is pressed', () => { + it('toggles the selected reaction when a reaction button is pressed twice', () => { const { getAllByLabelText } = renderComponent(); - const reactionButtons = getAllByLabelText(/\breaction-button[^\s]+/); + let reactionButtons = getAllByLabelText(/\breaction-button[^\s]+/); fireEvent.press(reactionButtons[1]); expect(reactionButtons[0].props.accessibilityLabel).toBe('reaction-button-like-unselected'); expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-selected'); + + fireEvent.press(reactionButtons[1]); + + reactionButtons = getAllByLabelText(/\breaction-button[^\s]+/); + + expect(reactionButtons[0].props.accessibilityLabel).toBe('reaction-button-like-unselected'); + expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-unselected'); }); it('renders reactions list', () => { diff --git a/package/src/components/UIComponents/BottomSheetModal.tsx b/package/src/components/UIComponents/BottomSheetModal.tsx index 46c88ddc76..df26e682f2 100644 --- a/package/src/components/UIComponents/BottomSheetModal.tsx +++ b/package/src/components/UIComponents/BottomSheetModal.tsx @@ -242,8 +242,10 @@ export const BottomSheetModal = (props: PropsWithChildren KeyboardControllerPackage.KeyboardEvents.addListener('keyboardDidHide', keyboardDidHide), ); } else { - listeners.push(Keyboard.addListener('keyboardDidShow', keyboardDidShowRN)); - listeners.push(Keyboard.addListener('keyboardDidHide', keyboardDidHide)); + if (Platform.OS === 'ios') { + listeners.push(Keyboard.addListener('keyboardWillShow', keyboardDidShowRN)); + listeners.push(Keyboard.addListener('keyboardWillHide', keyboardDidHide)); + } } return () => listeners.forEach((l) => l.remove()); diff --git a/package/src/icons/index.ts b/package/src/icons/index.ts index 8bf4e6db69..86239b3d0a 100644 --- a/package/src/icons/index.ts +++ b/package/src/icons/index.ts @@ -34,6 +34,7 @@ export * from './clock'; export * from './Unknown'; export * from './unpin'; export * from './video-fill'; +export * from './video'; export * from './user-add'; export * from './user-remove'; export * from './filetype-video-xl';