diff --git a/examples/ExpoMessaging/yarn.lock b/examples/ExpoMessaging/yarn.lock index 443e606059..01f8f87e8b 100644 --- a/examples/ExpoMessaging/yarn.lock +++ b/examples/ExpoMessaging/yarn.lock @@ -6043,10 +6043,10 @@ stream-chat-react-native-core@8.1.0: version "0.0.0" uid "" -stream-chat@^9.36.1: - version "9.36.2" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.36.2.tgz#cd2cfac1f8d7b045c34dce51e2de1cb66bf288f5" - integrity sha512-sSCxTXJOf0BLDMZ2/cqvFged/LLbiWoIhs7v3UsRj0EM0T8tTam7zpU77TSccNDlK5j1C1/llSUVyMLc7aCDsA== +stream-chat@^9.40.0: + version "9.41.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.41.0.tgz#ad88d7919aaf1d3c35b4a431a8cd464cb640f146" + integrity sha512-Rgp3vULGKYxHZ/aCeundly6ngdBGttTPz+YknmWhbqvNlEhPB/RM61CpQPHgPyfkSm+osJT3tEV9fKd+I/S77g== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" diff --git a/examples/SampleApp/src/components/NetworkDownIndicator.tsx b/examples/SampleApp/src/components/NetworkDownIndicator.tsx index 3c46e49c06..cefa61ea48 100644 --- a/examples/SampleApp/src/components/NetworkDownIndicator.tsx +++ b/examples/SampleApp/src/components/NetworkDownIndicator.tsx @@ -16,20 +16,19 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '700', }, - spinner: { - backgroundColor: 'white', - }, }); export const NetworkDownIndicator: React.FC<{ titleSize: 'small' | 'large' }> = ({ titleSize = 'small', }) => { - useTheme(); + const { + theme: { semantics }, + } = useTheme(); const { black } = useLegacyColors(); return ( - + = ({ height = 24, width = 24 }) => { const { black } = useLegacyColors(); + return ( - - + + + + ); }; diff --git a/examples/SampleApp/src/icons/GoForward.tsx b/examples/SampleApp/src/icons/GoForward.tsx index ff994a76bb..27dbb978ce 100644 --- a/examples/SampleApp/src/icons/GoForward.tsx +++ b/examples/SampleApp/src/icons/GoForward.tsx @@ -1,12 +1,13 @@ import React from 'react'; +import { I18nManager } from 'react-native'; import Svg, { G, Path } from 'react-native-svg'; import { IconProps } from '../utils/base'; export const GoForward: React.FC = ({ height = 20, width = 20, ...rest }) => { return ( - - + + = ({ channel }) => { opacity: pressed ? 0.5 : 1, })} > - + )} showUnreadCountBadge @@ -267,7 +267,7 @@ export const ChannelScreen: React.FC = ({ navigation, route = ({ navigation }) => { const { chatClient } = useAppContext(); + const searchInputRef = useRef(null); const { theme: { semantics }, @@ -97,6 +104,10 @@ export const NewGroupChannelAddMemberScreen: React.FC = ({ navigation }) navigation.navigate('NewGroupChannelAssignNameScreen'); }; + const focusSearchInput = useCallback(() => { + searchInputRef.current?.focus(); + }, []); + if (!chatClient) { return null; } @@ -122,12 +133,12 @@ export const NewGroupChannelAddMemberScreen: React.FC = ({ navigation }) }, ]} > - = ({ navigation }) ]} value={searchText} /> + + + = ({ { grey: semantics.textSecondary, grey_gainsboro: semantics.borderCoreDefault, grey_whisper: semantics.backgroundCoreSurfaceDefault, - icon_background: semantics.backgroundCoreApp, + icon_background: semantics.backgroundCoreElevation1, overlay: semantics.badgeBgOverlay, white: semantics.backgroundCoreApp, white_smoke: semantics.backgroundCoreSurfaceSubtle, diff --git a/examples/SampleApp/src/utils/messageActions.tsx b/examples/SampleApp/src/utils/messageActions.tsx index c791acd42c..341ac25b33 100644 --- a/examples/SampleApp/src/utils/messageActions.tsx +++ b/examples/SampleApp/src/utils/messageActions.tsx @@ -1,21 +1,21 @@ -import React from 'react'; -import { Alert } from 'react-native'; +// import React from 'react'; +// import { Alert } from 'react-native'; import { LocalMessage, StreamChat } from 'stream-chat'; import { Colors, messageActions, MessageActionsParams, TranslationContextValue, - Bell, + // Bell, } from 'stream-chat-react-native'; import { Theme } from 'stream-chat-react-native'; export function channelMessageActions({ params, - chatClient, - t, - // handleMessageInfo, - semantics, + // chatClient, + // t, + // // handleMessageInfo, + // semantics, }: { params: MessageActionsParams; chatClient: StreamChat; @@ -24,11 +24,11 @@ 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. - const reminder = chatClient.reminders.getFromState(params.message.id); + // const reminder = chatClient.reminders.getFromState(params.message.id); // actions.push({ // action: async () => { @@ -48,54 +48,54 @@ export function channelMessageActions({ // icon: , // type: 'standard', // }); - if (!error) { - actions.push({ - action: () => { - if (reminder) { - Alert.alert('Remove Reminder', 'Are you sure you want to remove this reminder?', [ - { - text: 'Cancel', - style: 'cancel', - }, - { - text: 'Remove', - onPress: () => { - chatClient.reminders.deleteReminder(reminder.id).catch((err) => { - console.error('Error deleting reminder:', err); - }); - }, - style: 'destructive', - }, - ]); - } else { - Alert.alert( - 'Select Reminder Time', - 'When would you like to be reminded?', - chatClient.reminders.scheduledOffsetsMs.map((offsetMs) => ({ - text: t('duration/Remind Me', { milliseconds: offsetMs }), - onPress: () => { - chatClient.reminders - .upsertReminder({ - messageId: params.message.id, - remind_at: new Date(new Date().getTime() + offsetMs).toISOString(), - }) - .catch((_error) => { - console.error('Error creating reminder:', _error); - }); - }, - style: 'default', - })), - ); - } - - dismissOverlay(); - }, - actionType: reminder ? 'remove-reminder' : 'remind-me', - title: reminder ? 'Remove Reminder' : 'Remind Me', - icon: , - type: 'standard', - }); - } + // if (!error) { + // actions.push({ + // action: () => { + // if (reminder) { + // Alert.alert('Remove Reminder', 'Are you sure you want to remove this reminder?', [ + // { + // text: 'Cancel', + // style: 'cancel', + // }, + // { + // text: 'Remove', + // onPress: () => { + // chatClient.reminders.deleteReminder(reminder.id).catch((err) => { + // console.error('Error deleting reminder:', err); + // }); + // }, + // style: 'destructive', + // }, + // ]); + // } else { + // Alert.alert( + // 'Select Reminder Time', + // 'When would you like to be reminded?', + // chatClient.reminders.scheduledOffsetsMs.map((offsetMs) => ({ + // text: t('duration/Remind Me', { milliseconds: offsetMs }), + // onPress: () => { + // chatClient.reminders + // .upsertReminder({ + // messageId: params.message.id, + // remind_at: new Date(new Date().getTime() + offsetMs).toISOString(), + // }) + // .catch((_error) => { + // console.error('Error creating reminder:', _error); + // }); + // }, + // style: 'default', + // })), + // ); + // } + // + // dismissOverlay(); + // }, + // actionType: reminder ? 'remove-reminder' : 'remind-me', + // title: reminder ? 'Remove Reminder' : 'Remind Me', + // icon: , + // type: 'standard', + // }); + // } // actions.push({ // action: async () => { // Alert.alert('Delete for me', 'Are you sure you want to delete this message for me?', [ diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 39f271402b..3a8542f53d 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -575,7 +575,7 @@ const ChannelWithContext = (props: PropsWithChildren) = asyncMessagesLockDistance = 50, asyncMessagesMinimumPressDuration = 500, asyncMessagesSlideToCancelDistance = 75, - audioRecordingSendOnComplete = true, + audioRecordingSendOnComplete = false, AttachButton = AttachButtonDefault, Attachment = AttachmentDefault, attachmentPickerBottomSheetHeight = disableAttachmentPicker ? 72 : 333, diff --git a/package/src/components/ChannelList/hooks/__tests__/useChannelActionItems.test.tsx b/package/src/components/ChannelList/hooks/__tests__/useChannelActionItems.test.tsx index 1ce3baf268..f0750915b3 100644 --- a/package/src/components/ChannelList/hooks/__tests__/useChannelActionItems.test.tsx +++ b/package/src/components/ChannelList/hooks/__tests__/useChannelActionItems.test.tsx @@ -90,31 +90,19 @@ describe('useChannelActionItems', () => { it('returns default channel action items', () => { const { result } = renderHook(() => useChannelActionItems({ channel })); - expect(result.current).toHaveLength(4); + expect(result.current).toHaveLength(3); expect(result.current.map((item) => item.action)).toEqual([ channelActions.muteChannel, - channelActions.archive, channelActions.leave, expect.any(Function), ]); - expect(result.current.map((item) => item.id)).toEqual([ - 'mute', - 'archive', - 'leave', - 'deleteChannel', - ]); + expect(result.current.map((item) => item.id)).toEqual(['mute', 'leave', 'deleteChannel']); expect(result.current.map((item) => item.type)).toEqual([ - 'standard', 'standard', 'destructive', 'destructive', ]); - expect(result.current.map((item) => item.placement)).toEqual([ - 'swipe', - 'both', - 'sheet', - 'sheet', - ]); + expect(result.current.map((item) => item.placement)).toEqual(['swipe', 'sheet', 'sheet']); }); it('uses custom getChannelActionItems with context and defaultItems when provided', () => { @@ -178,25 +166,18 @@ describe('getChannelActionItems', () => { expect(actionItems.map((item) => item.action)).toEqual([ channelActions.muteChannel, - channelActions.archive, channelActions.leave, expect.any(Function), ]); - expect(actionItems.map((item) => item.id)).toEqual([ - 'mute', - 'archive', - 'leave', - 'deleteChannel', - ]); + expect(actionItems.map((item) => item.id)).toEqual(['mute', 'leave', 'deleteChannel']); expect(actionItems.map((item) => item.type)).toEqual([ - 'standard', 'standard', 'destructive', 'destructive', ]); }); - it('returns direct-chat variants for mute, block and archive states', () => { + it('returns direct-chat variants for mute and block states', () => { const channelActions = createChannelActions(); const actionItems = buildDefaultChannelActionItems({ actions: channelActions, @@ -208,34 +189,20 @@ describe('getChannelActionItems', () => { t: (value) => value, }); - expect(actionItems.map((item) => item.id)).toEqual([ - 'mute', - 'block', - 'archive', - 'leave', - 'deleteChannel', - ]); + expect(actionItems.map((item) => item.id)).toEqual(['mute', 'block', 'leave', 'deleteChannel']); expect(actionItems.map((item) => item.action)).toEqual([ channelActions.unmuteUser, channelActions.unblockUser, - channelActions.unarchive, channelActions.leave, expect.any(Function), ]); expect(actionItems.map((item) => item.label)).toEqual([ 'Unmute User', 'Unblock User', - 'Unarchive Chat', 'Leave Chat', 'Delete Chat', ]); - expect(actionItems.map((item) => item.placement)).toEqual([ - 'sheet', - 'sheet', - 'sheet', - 'sheet', - 'sheet', - ]); + expect(actionItems.map((item) => item.placement)).toEqual(['sheet', 'sheet', 'sheet', 'sheet']); }); it('omits delete action when current user is not the channel creator', () => { @@ -249,10 +216,10 @@ describe('getChannelActionItems', () => { t: (value) => value, }); - expect(actionItems.map((item) => item.id)).toEqual(['mute', 'archive', 'leave']); + expect(actionItems.map((item) => item.id)).toEqual(['mute', 'leave']); }); - it('uses group variants for mute and archive labels and placements', () => { + it('uses group mute variants for labels and placements', () => { const channelActions = createChannelActions(); const actionItems = buildDefaultChannelActionItems({ actions: channelActions, @@ -268,9 +235,9 @@ describe('getChannelActionItems', () => { expect(actionItems[0].label).toBe('Unmute Group'); expect(actionItems[0].placement).toBe('swipe'); - expect(actionItems[1].action).toBe(channelActions.unarchive); - expect(actionItems[1].label).toBe('Unarchive Group'); - expect(actionItems[1].placement).toBe('both'); + expect(actionItems[1].action).toBe(channelActions.leave); + expect(actionItems[1].label).toBe('Leave Group'); + expect(actionItems[1].placement).toBe('sheet'); }); it('shows delete confirmation and calls deleteChannel on destructive confirm', async () => { diff --git a/package/src/components/ChannelList/hooks/useChannelActionItems.tsx b/package/src/components/ChannelList/hooks/useChannelActionItems.tsx index 6006396df8..68be87888a 100644 --- a/package/src/components/ChannelList/hooks/useChannelActionItems.tsx +++ b/package/src/components/ChannelList/hooks/useChannelActionItems.tsx @@ -12,7 +12,7 @@ import { useIsDirectChat } from './useIsDirectChat'; import { useTheme, useTranslationContext } from '../../../contexts'; import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; -import { Archive, IconProps, Mute, BlockUser, Delete, Sound } from '../../../icons'; +import { IconProps, Mute, BlockUser, Delete, Sound } from '../../../icons'; import { ArrowBoxLeft } from '../../../icons/leave'; export type ChannelActionHandler = () => Promise | void; @@ -56,10 +56,8 @@ export const buildDefaultChannelActionItems: BuildDefaultChannelActionItems = ( ) => { const { actions: { - archive, deleteChannel, leave, - unarchive, muteChannel, unmuteChannel, muteUser, @@ -67,7 +65,6 @@ export const buildDefaultChannelActionItems: BuildDefaultChannelActionItems = ( blockUser, unblockUser, }, - isArchived, isDirectChat, muteActive, t, @@ -128,21 +125,6 @@ export const buildDefaultChannelActionItems: BuildDefaultChannelActionItems = ( }); } - actionItems.push({ - action: isArchived ? unarchive : archive, - Icon: (props) => , - id: 'archive', - label: isDirectChat - ? isArchived - ? t('Unarchive Chat') - : t('Archive Chat') - : isArchived - ? t('Unarchive Group') - : t('Archive Group'), - placement: isDirectChat ? 'sheet' : 'both', - type: 'standard', - }); - actionItems.push({ action: leave, Icon: (props) => , diff --git a/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx b/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx index c139e05bfd..26ecdb2ccf 100644 --- a/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx +++ b/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx @@ -4,23 +4,25 @@ import type { FlatListProps } from 'react-native'; import { Pressable } from 'react-native-gesture-handler'; -import { Channel } from 'stream-chat'; +import type { Channel } from 'stream-chat'; import { ChannelPreviewMutedStatus } from './ChannelPreviewMutedStatus'; import { ChannelPreviewTitle } from './ChannelPreviewTitle'; import { useIsChannelMuted } from './hooks/useIsChannelMuted'; -import { useBottomSheetContext, useTheme, useTranslationContext } from '../../contexts'; +import { useBottomSheetContext } from '../../contexts/bottomSheetContext/BottomSheetContext'; import { useSwipeRegistryContext } from '../../contexts/swipeableContext/SwipeRegistryContext'; -import { useStableCallback } from '../../hooks'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; +import { useStableCallback } from '../../hooks/useStableCallback'; import { primitives } from '../../theme'; -import { ChannelActionItem } from '../ChannelList/hooks/useChannelActionItems'; +import type { 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'; -import { StreamBottomSheetModalFlatList } from '../UIComponents'; +import { ChannelAvatar } from '../ui/Avatar/ChannelAvatar'; +import { StreamBottomSheetModalFlatList } from '../UIComponents/StreamBottomSheetModalFlatList'; export type ChannelDetailsHeaderProps = { channel: Channel }; @@ -55,7 +57,7 @@ export const ChannelDetailsHeader = ({ channel }: ChannelDetailsHeaderProps) => return ( - + diff --git a/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx b/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx index 6e7eb96f82..3d3c875ec5 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import { StyleSheet, Text } from 'react-native'; -import { ChannelPreviewProps } from './ChannelPreview'; +import type { ChannelPreviewProps } from './ChannelPreview'; import type { ChannelPreviewViewPropsWithContext } from './ChannelPreviewView'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; diff --git a/package/src/components/ChannelPreview/ChannelPreviewView.tsx b/package/src/components/ChannelPreview/ChannelPreviewView.tsx index acf231abcd..f27a23fce6 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewView.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewView.tsx @@ -1,14 +1,14 @@ import React, { useMemo } from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; -import { ChannelPreviewProps } from './ChannelPreview'; +import type { ChannelPreviewProps } from './ChannelPreview'; import { ChannelPreviewMessage } from './ChannelPreviewMessage'; import { ChannelPreviewMutedStatus } from './ChannelPreviewMutedStatus'; import { ChannelPreviewStatus } from './ChannelPreviewStatus'; import { ChannelPreviewTitle } from './ChannelPreviewTitle'; import { ChannelPreviewUnreadCount } from './ChannelPreviewUnreadCount'; -import { LastMessageType } from './hooks/useChannelPreviewData'; +import type { LastMessageType } from './hooks/useChannelPreviewData'; import { ChannelsContextValue, @@ -204,6 +204,7 @@ const useStyles = () => { }, contentContainer: { flex: 1, gap: primitives.spacingXxs }, upperRow: { + gap: primitives.spacingMd, alignItems: 'center', flex: 1, flexDirection: 'row', diff --git a/package/src/components/ChannelPreview/ChannelSwipableWrapper.tsx b/package/src/components/ChannelPreview/ChannelSwipableWrapper.tsx index 1aaf7385b2..f35e38abd1 100644 --- a/package/src/components/ChannelPreview/ChannelSwipableWrapper.tsx +++ b/package/src/components/ChannelPreview/ChannelSwipableWrapper.tsx @@ -1,27 +1,27 @@ import React, { PropsWithChildren, useCallback, useMemo, useState } from 'react'; import { StyleSheet } from 'react-native'; -import { SharedValue } from 'react-native-reanimated'; +import type { SharedValue } from 'react-native-reanimated'; -import { Channel } from 'stream-chat'; +import type { 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 { useTheme } from '../../contexts/themeContext/ThemeContext'; import { MenuPointHorizontal, Mute, Sound } from '../../icons'; -import { GetChannelActionItems } from '../ChannelList/hooks/useChannelActionItems'; +import type { GetChannelActionItems } from '../ChannelList/hooks/useChannelActionItems'; import { useChannelActionItems } from '../ChannelList/hooks/useChannelActionItems'; import { useChannelActions } from '../ChannelList/hooks/useChannelActions'; +import { BottomSheetModal } from '../UIComponents/BottomSheetModal'; import { - BottomSheetModal, RightActions, SwipableActionItem, SwipableWrapper, SwipableWrapperProps, -} from '../UIComponents'; +} from '../UIComponents/SwipableWrapper'; export const OpenChannelDetailsButton = () => { const { @@ -120,9 +120,10 @@ export const ChannelSwipableWrapper = ({ {children} setChannelDetailSheetOpen(false)} visible={channelDetailSheetOpen} - height={356} + height={424} > diff --git a/package/src/components/ChannelPreview/__tests__/ChannelDetailsBottomSheet.test.tsx b/package/src/components/ChannelPreview/__tests__/ChannelDetailsBottomSheet.test.tsx index 3697fc6320..5a29066f5c 100644 --- a/package/src/components/ChannelPreview/__tests__/ChannelDetailsBottomSheet.test.tsx +++ b/package/src/components/ChannelPreview/__tests__/ChannelDetailsBottomSheet.test.tsx @@ -11,7 +11,7 @@ import { ChannelDetailsBottomSheet } from '../ChannelDetailsBottomSheet'; const mockStreamBottomSheetModalFlatList = jest.fn(() => null); -jest.mock('../../UIComponents', () => ({ +jest.mock('../../UIComponents/StreamBottomSheetModalFlatList', () => ({ StreamBottomSheetModalFlatList: (...args: unknown[]) => mockStreamBottomSheetModalFlatList(...args), })); diff --git a/package/src/components/ChannelPreview/__tests__/ChannelLastMessagePreview.test.tsx b/package/src/components/ChannelPreview/__tests__/ChannelLastMessagePreview.test.tsx new file mode 100644 index 0000000000..2e96f2b2db --- /dev/null +++ b/package/src/components/ChannelPreview/__tests__/ChannelLastMessagePreview.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import { render } from '@testing-library/react-native'; + +import { generateGiphyAttachment } from '../../../mock-builders/generator/attachment'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { generateUser } from '../../../mock-builders/generator/user'; +import { getTestClientWithUser } from '../../../mock-builders/mock'; +import { Chat } from '../../Chat/Chat'; +import { ChannelLastMessagePreview } from '../ChannelLastMessagePreview'; + +describe('ChannelLastMessagePreview', () => { + it('shows Giphy instead of slash-command text for giphy attachments with quoted replies', async () => { + const client = await getTestClientWithUser({ id: 'preview-user' }); + const user = generateUser(); + const message = generateMessage({ + attachments: [generateGiphyAttachment()], + quoted_message: generateMessage({ text: 'quoted message', user }), + text: '/giphy Cat', + user, + }); + + const { getByText, queryByText } = render( + + + , + ); + + expect(getByText('Giphy')).toBeTruthy(); + expect(queryByText('/giphy Cat')).toBeNull(); + }); +}); diff --git a/package/src/components/ChannelPreview/__tests__/ChannelSwipableWrapper.test.tsx b/package/src/components/ChannelPreview/__tests__/ChannelSwipableWrapper.test.tsx index 318fd9653d..d3855758fd 100644 --- a/package/src/components/ChannelPreview/__tests__/ChannelSwipableWrapper.test.tsx +++ b/package/src/components/ChannelPreview/__tests__/ChannelSwipableWrapper.test.tsx @@ -31,7 +31,7 @@ const mockSwipableWrapper = jest.fn( }, ); -jest.mock('../../../contexts', () => ({ +jest.mock('../../../contexts/themeContext/ThemeContext', () => ({ useTheme: () => ({ theme: { semantics: { @@ -50,8 +50,11 @@ jest.mock('../../../contexts/swipeableContext/SwipeRegistryContext', () => ({ }), })); -jest.mock('../../UIComponents', () => ({ +jest.mock('../../UIComponents/BottomSheetModal', () => ({ BottomSheetModal: ({ children }: React.PropsWithChildren) => <>{children}, +})); + +jest.mock('../../UIComponents/SwipableWrapper', () => ({ RightActions: ({ items }: { items: Array<{ action: () => void; id: string }> }) => { rightActionsProbe.items = items; return null; diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index dbca30c0e4..6ee7445381 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -489,7 +489,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const messageContentOrder = messageContentOrderProp.filter((content) => { if (content === 'quoted_reply') { - return !!message.quoted_message; + return !!message.quoted_message && !hasAttachmentActions; } switch (content) { @@ -700,6 +700,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { goToMessage, groupStyles, handleAction, + hasAttachmentActions, handleReaction, handleToggleReaction, hasReactions, diff --git a/package/src/components/Message/MessageItemView/MessageItemView.tsx b/package/src/components/Message/MessageItemView/MessageItemView.tsx index fb2b658f59..765dccbded 100644 --- a/package/src/components/Message/MessageItemView/MessageItemView.tsx +++ b/package/src/components/Message/MessageItemView/MessageItemView.tsx @@ -200,6 +200,7 @@ export type MessageItemViewPropsWithContext = Pick< | 'alignment' | 'channel' | 'groupStyles' + | 'hasAttachmentActions' | 'isMyMessage' | 'message' | 'onlyEmojis' @@ -241,6 +242,7 @@ const MessageItemViewWithContext = (props: MessageItemViewPropsWithContext) => { enableMessageGroupingByUser, enableSwipeToReply, groupStyles, + hasAttachmentActions, isMyMessage, message, MessageAuthor, @@ -294,12 +296,13 @@ const MessageItemViewWithContext = (props: MessageItemViewPropsWithContext) => { }); const groupStyle = `${alignment}_${groupStyles?.[0]?.toLowerCase?.()}`; + const hasVisibleQuotedReply = !!message.quoted_message && !hasAttachmentActions; const hasStandaloneGiphyOrImgur = - !message.quoted_message && + !hasVisibleQuotedReply && otherAttachments.length > 0 && (otherAttachments[0].type === FileTypes.Giphy || otherAttachments[0].type === FileTypes.Imgur); - let noBorder = onlyEmojis && !message.quoted_message; + let noBorder = onlyEmojis && !hasVisibleQuotedReply; if (otherAttachments.length) { if (hasStandaloneGiphyOrImgur && !isMyMessage) { noBorder = false; @@ -309,7 +312,7 @@ const MessageItemViewWithContext = (props: MessageItemViewPropsWithContext) => { } let backgroundColor = semantics.chatBgOutgoing; - if (onlyEmojis && !message.quoted_message) { + if (onlyEmojis && !hasVisibleQuotedReply) { backgroundColor = 'transparent'; } else if (hasStandaloneGiphyOrImgur) { backgroundColor = 'transparent'; @@ -522,6 +525,7 @@ export const MessageItemView = (props: MessageItemViewProps) => { alignment, channel, groupStyles, + hasAttachmentActions, isMyMessage, message, contextMenuAnchorRef, @@ -563,6 +567,7 @@ export const MessageItemView = (props: MessageItemViewProps) => { enableMessageGroupingByUser, enableSwipeToReply, groupStyles, + hasAttachmentActions, isMyMessage, message, MessageAuthor, diff --git a/package/src/components/Message/MessageItemView/__tests__/MessageContent.test.js b/package/src/components/Message/MessageItemView/__tests__/MessageContent.test.js index 994c967702..515f50b7b9 100644 --- a/package/src/components/Message/MessageItemView/__tests__/MessageContent.test.js +++ b/package/src/components/Message/MessageItemView/__tests__/MessageContent.test.js @@ -8,6 +8,7 @@ import { ChannelsStateProvider } from '../../../../contexts/channelsStateContext import { getOrCreateChannelApi } from '../../../../mock-builders/api/getOrCreateChannel'; import { useMockedApis } from '../../../../mock-builders/api/useMockedApis'; import { + generateAttachmentAction, generateGiphyAttachment, generateVideoAttachment, } from '../../../../mock-builders/generator/attachment'; @@ -369,6 +370,33 @@ describe('MessageContent', () => { expect(contentContainerStyle.paddingBottom).toBeGreaterThan(0); }); + it('does not render the quoted reply for an ephemeral giphy preview', async () => { + const user = generateUser(); + const message = generateMessage({ + attachments: [ + { + ...generateGiphyAttachment(), + actions: [ + generateAttachmentAction(), + generateAttachmentAction(), + generateAttachmentAction(), + ], + }, + ], + quoted_message: generateMessage({ text: 'quoted message', user }), + text: '', + user, + }); + + renderMessage({ message }); + + await waitFor(() => { + expect(screen.getByTestId('giphy-action-attachment')).toBeTruthy(); + }); + + expect(screen.queryByText('quoted message')).toBeFalsy(); + }); + it('renders the FileAttachment component when a file attachment exists', async () => { const user = generateUser(); const message = generateMessage({ diff --git a/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.js b/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.js index 367d4a67f3..2f45bfbcff 100644 --- a/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.js +++ b/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.js @@ -10,7 +10,10 @@ import { useMessageContext } from '../../../../contexts/messageContext/MessageCo import { getOrCreateChannelApi } from '../../../../mock-builders/api/getOrCreateChannel'; import { useMockedApis } from '../../../../mock-builders/api/useMockedApis'; -import { generateGiphyAttachment } from '../../../../mock-builders/generator/attachment'; +import { + generateAttachmentAction, + generateGiphyAttachment, +} from '../../../../mock-builders/generator/attachment'; import { generateChannelResponse } from '../../../../mock-builders/generator/channel'; import { generateMember } from '../../../../mock-builders/generator/member'; import { generateMessage } from '../../../../mock-builders/generator/message'; @@ -276,4 +279,32 @@ describe('MessageItemView', () => { expect(wrapperStyle.backgroundColor).not.toBe('transparent'); }); }); + + it('renders a standalone shell for an ephemeral giphy preview with a quoted reply', async () => { + const user = generateUser(); + const message = generateMessage({ + attachments: [ + { + ...generateGiphyAttachment(), + actions: [ + generateAttachmentAction(), + generateAttachmentAction(), + generateAttachmentAction(), + ], + }, + ], + quoted_message: generateMessage({ text: 'quoted message', user }), + text: '', + user, + }); + + renderMessage({ message }); + + await waitFor(() => { + const wrapperStyle = StyleSheet.flatten( + screen.getByTestId('message-content-wrapper').props.style, + ); + expect(wrapperStyle.backgroundColor).toBe('transparent'); + }); + }); }); diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts index b109827be6..55e176e95d 100644 --- a/package/src/components/Message/hooks/useCreateMessageContext.ts +++ b/package/src/components/Message/hooks/useCreateMessageContext.ts @@ -25,6 +25,7 @@ export const useCreateMessageContext = ({ goToMessage, groupStyles, handleAction, + hasAttachmentActions, handleReaction, handleToggleReaction, hasReactions, @@ -79,6 +80,7 @@ export const useCreateMessageContext = ({ goToMessage, groupStyles: stableGroupStyles, handleAction, + hasAttachmentActions, handleReaction, handleToggleReaction, hasReactions, @@ -116,6 +118,7 @@ export const useCreateMessageContext = ({ alignment, goToMessage, stableGroupStyles, + hasAttachmentActions, hasReactions, messageHasOnlySingleAttachment, lastGroupMessage, diff --git a/package/src/components/MessageMenu/MessageUserReactions.tsx b/package/src/components/MessageMenu/MessageUserReactions.tsx index 1bb6728122..6163a0caa7 100644 --- a/package/src/components/MessageMenu/MessageUserReactions.tsx +++ b/package/src/components/MessageMenu/MessageUserReactions.tsx @@ -280,11 +280,7 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { ) : ( - + {t('{{count}} Reactions', { count: totalReactionCount })} diff --git a/package/src/components/Poll/components/PollInputDialog.tsx b/package/src/components/Poll/components/PollInputDialog.tsx index 1f923d3ec8..3032a1d92d 100644 --- a/package/src/components/Poll/components/PollInputDialog.tsx +++ b/package/src/components/Poll/components/PollInputDialog.tsx @@ -81,7 +81,7 @@ export const PollInputDialog = ({ style={styles.button} /> ) : null} @@ -378,6 +554,14 @@ export const BottomSheetModal = (props: PropsWithChildren ); }; +export const BottomSheetModal = (props: PropsWithChildren) => { + if (!props.visible) { + return null; + } + + return ; +}; + const useStyles = () => { const { theme: { semantics }, diff --git a/package/src/components/UIComponents/BottomSheetModal.utils.ts b/package/src/components/UIComponents/BottomSheetModal.utils.ts new file mode 100644 index 0000000000..a35372d646 --- /dev/null +++ b/package/src/components/UIComponents/BottomSheetModal.utils.ts @@ -0,0 +1,82 @@ +export const BOTTOM_SHEET_HANDLE_HEIGHT = 4; +export const BOTTOM_SHEET_HANDLE_MARGIN_VERTICAL = 8; +export const BOTTOM_SHEET_HANDLE_TOTAL_HEIGHT = + BOTTOM_SHEET_HANDLE_HEIGHT + BOTTOM_SHEET_HANDLE_MARGIN_VERTICAL * 2; + +type GetBottomSheetBaseHeightParams = { + contentHeight: number | undefined; + enableDynamicSizing: boolean; + height: number; + maxHeight: number; +}; + +export const getBottomSheetBaseHeight = ({ + contentHeight, + enableDynamicSizing, + height, + maxHeight, +}: GetBottomSheetBaseHeightParams) => { + 'worklet'; + + const cappedHeight = Math.min(height, maxHeight); + + if (!enableDynamicSizing || contentHeight === undefined) { + return cappedHeight; + } + + const measuredHeight = Math.max(0, contentHeight) + BOTTOM_SHEET_HANDLE_TOTAL_HEIGHT; + + return Math.min(measuredHeight, cappedHeight); +}; + +type GetBottomSheetSnapPointsParams = { + baseHeight: number; + maxHeight: number; +}; + +export const getBottomSheetSnapPoints = ({ + baseHeight, + maxHeight, +}: GetBottomSheetSnapPointsParams) => { + 'worklet'; + + if (baseHeight === maxHeight) { + return [baseHeight]; + } + + return [baseHeight, maxHeight]; +}; + +type GetBottomSheetTopSnapIndexParams = { + baseHeight: number; + maxHeight: number; +}; + +export const getBottomSheetTopSnapIndex = ({ + baseHeight, + maxHeight, +}: GetBottomSheetTopSnapIndexParams) => { + 'worklet'; + + return baseHeight === maxHeight ? 0 : 1; +}; + +type GetBottomSheetSnapPointTranslateYParams = { + baseHeight: number; + maxHeight: number; + snapIndex: number; +}; + +export const getBottomSheetSnapPointTranslateY = ({ + baseHeight, + maxHeight, + snapIndex, +}: GetBottomSheetSnapPointTranslateYParams) => { + 'worklet'; + + if (getBottomSheetTopSnapIndex({ baseHeight, maxHeight }) === 0) { + return 0; + } + + return snapIndex <= 0 ? maxHeight - baseHeight : 0; +}; diff --git a/package/src/components/UIComponents/StreamBottomSheetModalFlatList.tsx b/package/src/components/UIComponents/StreamBottomSheetModalFlatList.tsx index a500ab4d41..a4f855fa08 100644 --- a/package/src/components/UIComponents/StreamBottomSheetModalFlatList.tsx +++ b/package/src/components/UIComponents/StreamBottomSheetModalFlatList.tsx @@ -12,7 +12,7 @@ export const StreamBottomSheetModalFlatList = ({ scrollEnabled: scrollEnabledProp, ...props }: StreamBottomSheetModalFlatListProps) => { - const { currentSnapIndex } = useBottomSheetContext(); + const { currentSnapIndex, topSnapIndex } = useBottomSheetContext(); const listRef = useRef>(null); const setNativeScrollEnabled = useStableCallback((value: boolean) => @@ -20,14 +20,23 @@ export const StreamBottomSheetModalFlatList = ({ ); useAnimatedReaction( - () => currentSnapIndex.value, + () => ({ + currentSnapIndex: currentSnapIndex.value, + topSnapIndex: topSnapIndex.value, + }), (value, prev) => { - if (value === prev || scrollEnabledProp !== undefined) { + if ( + scrollEnabledProp !== undefined || + (prev && + value.currentSnapIndex === prev.currentSnapIndex && + value.topSnapIndex === prev.topSnapIndex) + ) { return; } - runOnJS(setNativeScrollEnabled)(value === 1); + + runOnJS(setNativeScrollEnabled)(value.currentSnapIndex === value.topSnapIndex); }, - [currentSnapIndex], + [currentSnapIndex, topSnapIndex], ); return ; diff --git a/package/src/components/UIComponents/__tests__/BottomSheetModal.utils.test.ts b/package/src/components/UIComponents/__tests__/BottomSheetModal.utils.test.ts new file mode 100644 index 0000000000..aa989d676f --- /dev/null +++ b/package/src/components/UIComponents/__tests__/BottomSheetModal.utils.test.ts @@ -0,0 +1,111 @@ +import { + BOTTOM_SHEET_HANDLE_TOTAL_HEIGHT, + getBottomSheetBaseHeight, + getBottomSheetSnapPointTranslateY, + getBottomSheetSnapPoints, + getBottomSheetTopSnapIndex, +} from '../BottomSheetModal.utils'; + +describe('BottomSheetModal.utils', () => { + it('caps fixed bottom sheet height by maxHeight', () => { + expect( + getBottomSheetBaseHeight({ + contentHeight: undefined, + enableDynamicSizing: false, + height: 500, + maxHeight: 420, + }), + ).toBe(420); + }); + + it('uses measured content height plus handle and safe area when dynamic sizing is enabled', () => { + expect( + getBottomSheetBaseHeight({ + contentHeight: 120, + enableDynamicSizing: true, + height: 400, + maxHeight: 500, + }), + ).toBe(120 + BOTTOM_SHEET_HANDLE_TOTAL_HEIGHT); + }); + + it('caps dynamic content height by the provided height', () => { + expect( + getBottomSheetBaseHeight({ + contentHeight: 500, + enableDynamicSizing: true, + height: 300, + maxHeight: 600, + }), + ).toBe(300); + }); + + it('keeps the expanded snap point when the sheet is shorter than maxHeight', () => { + expect( + getBottomSheetSnapPoints({ + baseHeight: 280, + maxHeight: 640, + }), + ).toEqual([280, 640]); + }); + + it('returns two snap points for fixed sizing when maxHeight exceeds baseHeight', () => { + expect( + getBottomSheetSnapPoints({ + baseHeight: 280, + maxHeight: 640, + }), + ).toEqual([280, 640]); + }); + + it('returns a single snap point when the sheet is already at maxHeight', () => { + expect( + getBottomSheetSnapPoints({ + baseHeight: 640, + maxHeight: 640, + }), + ).toEqual([640]); + }); + + it('returns the correct top snap index', () => { + expect( + getBottomSheetTopSnapIndex({ + baseHeight: 280, + maxHeight: 640, + }), + ).toBe(1); + + expect( + getBottomSheetTopSnapIndex({ + baseHeight: 640, + maxHeight: 640, + }), + ).toBe(0); + }); + + it('returns the correct translateY for each snap point', () => { + expect( + getBottomSheetSnapPointTranslateY({ + baseHeight: 280, + maxHeight: 640, + snapIndex: 0, + }), + ).toBe(360); + + expect( + getBottomSheetSnapPointTranslateY({ + baseHeight: 280, + maxHeight: 640, + snapIndex: 1, + }), + ).toBe(0); + + expect( + getBottomSheetSnapPointTranslateY({ + baseHeight: 640, + maxHeight: 640, + snapIndex: 0, + }), + ).toBe(0); + }); +}); diff --git a/package/src/components/ui/Avatar/ChannelAvatar.tsx b/package/src/components/ui/Avatar/ChannelAvatar.tsx index 5de72d5a6d..6253ada889 100644 --- a/package/src/components/ui/Avatar/ChannelAvatar.tsx +++ b/package/src/components/ui/Avatar/ChannelAvatar.tsx @@ -58,7 +58,11 @@ export const ChannelAvatar = (props: ChannelAvatarProps) => { if (usersWithoutSelf.length > 1) { return ( - + ); } else { return ( diff --git a/package/src/contexts/bottomSheetContext/BottomSheetContext.tsx b/package/src/contexts/bottomSheetContext/BottomSheetContext.tsx index 3214618f0e..ad0bcd44bf 100644 --- a/package/src/contexts/bottomSheetContext/BottomSheetContext.tsx +++ b/package/src/contexts/bottomSheetContext/BottomSheetContext.tsx @@ -8,6 +8,7 @@ import { isTestEnvironment } from '../utils/isTestEnvironment'; export type BottomSheetContextValue = { close: (closeAnimationFinishedCallback?: () => void) => void; currentSnapIndex: SharedValue; + topSnapIndex: SharedValue; }; export const BottomSheetContext = React.createContext( diff --git a/package/src/contexts/messageComposerContext/MessageComposerContext.tsx b/package/src/contexts/messageComposerContext/MessageComposerContext.tsx index 49e058bdb1..3d79099319 100644 --- a/package/src/contexts/messageComposerContext/MessageComposerContext.tsx +++ b/package/src/contexts/messageComposerContext/MessageComposerContext.tsx @@ -8,7 +8,7 @@ import { } from './MessageComposerAPIContext'; import { ChannelProps } from '../../components'; -import { useStableCallback } from '../../hooks'; +import { useStableCallback } from '../../hooks/useStableCallback'; import { useCreateMessageComposer } from '../messageInputContext/hooks/useCreateMessageComposer'; import { ThreadContextValue } from '../threadContext/ThreadContext'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index 58ac45eb66..934344e328 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -4,7 +4,7 @@ import type { View } from 'react-native'; import type { Attachment, LocalMessage } from 'stream-chat'; import type { ActionHandler } from '../../components/Attachment/Attachment'; -import { ReactionSummary } from '../../components/Message/hooks/useProcessReactions'; +import type { ReactionSummary } from '../../components/Message/hooks/useProcessReactions'; import type { MessagePressableHandlerPayload, PressableHandlerPayload, @@ -15,7 +15,7 @@ import type { MessageContentType } from '../../contexts/messagesContext/Messages import type { DeepPartial } from '../../contexts/themeContext/ThemeContext'; import type { Theme } from '../../contexts/themeContext/utils/theme'; -import { MessageComposerAPIContextValue } from '../messageComposerContext/MessageComposerAPIContext'; +import type { MessageComposerAPIContextValue } from '../messageComposerContext/MessageComposerAPIContext'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; export type Alignment = 'right' | 'left'; @@ -40,6 +40,8 @@ export type MessageContextValue = { groupStyles: GroupType[]; /** Handler for actions. Actions in combination with attachments can be used to build [commands](https://getstream.io/chat/docs/#channel_commands). */ handleAction: ActionHandler; + /** Whether or not any message attachment exposes actions. */ + hasAttachmentActions: boolean; handleToggleReaction: (reactionType: string) => Promise; /** Whether or not message has reactions */ hasReactions: boolean; diff --git a/package/src/hooks/messagePreview/useMessagePreviewText.tsx b/package/src/hooks/messagePreview/useMessagePreviewText.tsx index 9e5d246da6..3db2f16da8 100644 --- a/package/src/hooks/messagePreview/useMessagePreviewText.tsx +++ b/package/src/hooks/messagePreview/useMessagePreviewText.tsx @@ -35,7 +35,6 @@ export const useMessagePreviewText = ({ const onlyAudio = audios?.length === attachmentsLength; const onlyVoiceRecordings = voiceRecordings?.length && voiceRecordings?.length === attachmentsLength; - if (message?.type === 'deleted') { return 'Message deleted'; } @@ -55,8 +54,12 @@ export const useMessagePreviewText = ({ return 'Location'; } + if (giphys?.length) { + return 'Giphy'; + } + if (message?.text) { - return message?.text; + return message.text; } if (onlyImages) { @@ -91,10 +94,6 @@ export const useMessagePreviewText = ({ } } - if (giphys?.length) { - return 'Giphy'; - } - if (onlyFiles && files?.length === 1) { return files?.[0]?.title; }