diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index b71b8c6a67..4bdc24765c 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -60,6 +60,7 @@ import { Toast } from './src/components/ToastComponent/Toast'; import { useClientNotificationsToastHandler } from './src/hooks/useClientNotificationsToastHandler'; import AsyncStore from './src/utils/AsyncStore.ts'; import { + MessageInputFloatingConfigItem, MessageListImplementationConfigItem, MessageListModeConfigItem, MessageListPruningConfigItem, @@ -106,6 +107,9 @@ const App = () => { const [messageListPruning, setMessageListPruning] = useState< MessageListPruningConfigItem['value'] | undefined >(undefined); + const [messageInputFloating, setMessageInputFloating] = useState< + MessageInputFloatingConfigItem['value'] | undefined + >(undefined); const colorScheme = useColorScheme(); const streamChatTheme = useStreamChatTheme(); const streami18n = new Streami18n(); @@ -161,6 +165,10 @@ const App = () => { '@stream-rn-sampleapp-messagelist-pruning', { value: undefined }, ); + const messageInputFloatingStoredValue = await AsyncStore.getItem( + '@stream-rn-sampleapp-messageinput-floating', + { value: false }, + ); setMessageListImplementation( messageListImplementationStoredValue?.id as MessageListImplementationConfigItem['id'], ); @@ -168,6 +176,9 @@ const App = () => { setMessageListPruning( messageListPruningStoredValue?.value as MessageListPruningConfigItem['value'], ); + setMessageInputFloating( + messageInputFloatingStoredValue?.value as MessageInputFloatingConfigItem['value'], + ); }; getMessageListConfig(); return () => { @@ -232,6 +243,7 @@ const App = () => { logout, switchUser, messageListImplementation, + messageInputFloating: messageInputFloating ?? false, messageListMode, messageListPruning, }} diff --git a/examples/SampleApp/src/components/SecretMenu.tsx b/examples/SampleApp/src/components/SecretMenu.tsx index 0037645b82..aff708538f 100644 --- a/examples/SampleApp/src/components/SecretMenu.tsx +++ b/examples/SampleApp/src/components/SecretMenu.tsx @@ -26,6 +26,7 @@ export type NotificationConfigItem = { label: string; name: string; id: string } export type MessageListImplementationConfigItem = { label: string; id: 'flatlist' | 'flashlist' }; export type MessageListModeConfigItem = { label: string; mode: 'default' | 'livestream' }; export type MessageListPruningConfigItem = { label: string; value: 100 | 500 | 1000 | undefined }; +export type MessageInputFloatingConfigItem = { label: string; value: boolean }; const messageListImplementationConfigItems: MessageListImplementationConfigItem[] = [ { label: 'FlatList', id: 'flatlist' }, @@ -44,6 +45,11 @@ const messageListPruningConfigItems: MessageListPruningConfigItem[] = [ { label: '1000 Messages', value: 1000 }, ]; +const messageInputFloatingConfigItems: MessageInputFloatingConfigItem[] = [ + { label: 'Normal', value: false }, + { label: 'Floating', value: true }, +]; + export const SlideInView = ({ visible, children, @@ -161,6 +167,23 @@ const SecretMenuMessageListImplementationConfigItem = ({ ); +const SecretMenuMessageInputFloatingConfigItem = ({ + messageInputFloatingConfigItem, + storeMessageInputFloating, + isSelected, +}: { + messageInputFloatingConfigItem: MessageInputFloatingConfigItem; + storeMessageInputFloating: (item: MessageInputFloatingConfigItem) => void; + isSelected: boolean; +}) => ( + storeMessageInputFloating(messageInputFloatingConfigItem)} + > + {messageInputFloatingConfigItem.label} + +); + const SecretMenuMessageListModeConfigItem = ({ messageListModeConfigItem, storeMessageListMode, @@ -218,6 +241,8 @@ export const SecretMenu = ({ const [selectedMessageListPruning, setSelectedMessageListPruning] = useState< MessageListPruningConfigItem['value'] | null >(null); + const [selectedMessageInputFloating, setSelectedMessageInputFloating] = + useState(false); const { theme: { colors: { black, grey }, @@ -250,12 +275,19 @@ export const SecretMenu = ({ '@stream-rn-sampleapp-messagelist-pruning', messageListPruningConfigItems[0], ); + const messageInputFloating = await AsyncStore.getItem( + '@stream-rn-sampleapp-messageinput-floating', + messageInputFloatingConfigItems[0], + ); setSelectedProvider(notificationProvider?.id ?? notificationConfigItems[0].id); setSelectedMessageListImplementation( messageListImplementation?.id ?? messageListImplementationConfigItems[0].id, ); setSelectedMessageListMode(messageListMode?.mode ?? messageListModeConfigItems[0].mode); setSelectedMessageListPruning(messageListPruning?.value); + setSelectedMessageInputFloating( + messageInputFloating?.value ?? messageInputFloatingConfigItems[0].value, + ); }; getSelectedConfig(); }, [notificationConfigItems]); @@ -283,6 +315,11 @@ export const SecretMenu = ({ setSelectedMessageListPruning(item.value); }, []); + const storeMessageInputFloating = useCallback(async (item: MessageInputFloatingConfigItem) => { + await AsyncStore.setItem('@stream-rn-sampleapp-messageinput-floating', item); + setSelectedMessageInputFloating(item.value); + }, []); + const removeAllDevices = useCallback(async () => { const { devices } = await chatClient.getDevices(chatClient.userID); for (const device of devices ?? []) { @@ -335,6 +372,22 @@ export const SecretMenu = ({ + + + + Message Input Floating + + {messageInputFloatingConfigItems.map((item) => ( + + ))} + + + diff --git a/examples/SampleApp/src/context/AppContext.ts b/examples/SampleApp/src/context/AppContext.ts index 9ec7c80990..b0e7921d67 100644 --- a/examples/SampleApp/src/context/AppContext.ts +++ b/examples/SampleApp/src/context/AppContext.ts @@ -4,6 +4,7 @@ import type { StreamChat } from 'stream-chat'; import type { LoginConfig } from '../types'; import { + MessageInputFloatingConfigItem, MessageListImplementationConfigItem, MessageListModeConfigItem, MessageListPruningConfigItem, @@ -15,6 +16,7 @@ type AppContextType = { logout: () => void; switchUser: (userId?: string) => void; messageListImplementation: MessageListImplementationConfigItem['id']; + messageInputFloating: MessageInputFloatingConfigItem['value']; messageListMode: MessageListModeConfigItem['mode']; messageListPruning: MessageListPruningConfigItem['value']; }; diff --git a/examples/SampleApp/src/screens/ChannelListScreen.tsx b/examples/SampleApp/src/screens/ChannelListScreen.tsx index 796406673d..8bc0d965cf 100644 --- a/examples/SampleApp/src/screens/ChannelListScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelListScreen.tsx @@ -9,7 +9,7 @@ import { View, } from 'react-native'; import { useNavigation, useScrollToTop } from '@react-navigation/native'; -import { ChannelList, CircleClose, Search, useTheme } from 'stream-chat-react-native'; +import { ChannelList, CircleClose, useTheme } from 'stream-chat-react-native'; import { Channel } from 'stream-chat'; import { ChannelPreview } from '../components/ChannelPreview'; import { ChatScreenHeader } from '../components/ChatScreenHeader'; @@ -19,6 +19,7 @@ import { usePaginatedSearchedMessages } from '../hooks/usePaginatedSearchedMessa import type { ChannelSort } from 'stream-chat'; import { useStreamChatContext } from '../context/StreamChatContext'; +import { Search } from '../icons/Search'; const styles = StyleSheet.create({ channelListContainer: { diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index ba4898e133..70b489cca4 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -20,7 +20,6 @@ import { } from 'stream-chat-react-native'; import { Pressable, StyleSheet, View } from 'react-native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useAppContext } from '../context/AppContext'; import { ScreenHeader } from '../components/ScreenHeader'; @@ -122,9 +121,13 @@ export const ChannelScreen: React.FC = ({ params: { channel: channelFromProp, channelId, messageId }, }, }) => { - const { chatClient, messageListImplementation, messageListMode, messageListPruning } = - useAppContext(); - const { bottom } = useSafeAreaInsets(); + const { + chatClient, + messageListImplementation, + messageListMode, + messageListPruning, + messageInputFloating, + } = useAppContext(); const { theme: { colors }, } = useTheme(); @@ -218,11 +221,12 @@ export const ChannelScreen: React.FC = ({ } return ( - + ({ parentMessage: nextValue.parentMessage }) as const; @@ -84,6 +86,8 @@ export const ThreadScreen: React.FC = ({ const { client: chatClient } = useChatContext(); const { t } = useTranslationContext(); const { setThread } = useStreamChatContext(); + const { messageInputFloating } = useAppContext(); + const headerHeight = useHeaderHeight(); const onPressMessage: NonNullable['onPressMessage']> = ( payload, @@ -115,14 +119,15 @@ export const ThreadScreen: React.FC = ({ }, [setThread]); return ( - + = ({ - + ); }; diff --git a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx index a49bfe7c7e..ac23dd1054 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx @@ -3,7 +3,6 @@ import { I18nManager, TextInput as RNTextInput, StyleSheet, - TextInputContentSizeChangeEvent, TextInputProps, TextInputSelectionChangeEvent, } from 'react-native'; @@ -35,7 +34,7 @@ type AutoCompleteInputPropsWithContext = TextInputProps & * This is currently passed in from MessageInput to avoid rerenders * that would happen if we put this in the MessageInputContext */ - cooldownActive?: boolean; + cooldownRemainingSeconds?: number; TextInputComponent?: React.ComponentType< TextInputProps & { ref: React.Ref | undefined; @@ -55,18 +54,19 @@ const configStateSelector = (state: MessageComposerConfig) => ({ }); const MAX_NUMBER_OF_LINES = 5; +const LINE_HEIGHT = 20; +const PADDING_VERTICAL = 12; const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) => { const { channel, - cooldownActive = false, + cooldownRemainingSeconds, setInputBoxRef, t, TextInputComponent = RNTextInput, ...rest } = props; const [localText, setLocalText] = useState(''); - const [textHeight, setTextHeight] = useState(0); const messageComposer = useMessageComposer(); const { textComposer } = messageComposer; const { command, text } = useStateStore(textComposer.state, textComposerStateSelector); @@ -115,15 +115,12 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) } = useTheme(); const placeholderText = useMemo(() => { - return command ? t('Search') : cooldownActive ? t('Slow mode ON') : t('Send a message'); - }, [command, cooldownActive, t]); - - const handleContentSizeChange = useCallback( - ({ nativeEvent: { contentSize } }: TextInputContentSizeChangeEvent) => { - setTextHeight(contentSize.height); - }, - [], - ); + return command + ? t('Search') + : cooldownRemainingSeconds + ? `Slow mode, wait ${cooldownRemainingSeconds}s...` + : t('Send a message'); + }, [command, cooldownRemainingSeconds, t]); return ( { - const { channel: prevChannel, cooldownActive: prevCooldownActive, t: prevT } = prevProps; - const { channel: nextChannel, cooldownActive: nextCooldownActive, t: nextT } = nextProps; + const { + channel: prevChannel, + cooldownRemainingSeconds: prevCooldownRemainingSeconds, + t: prevT, + } = prevProps; + const { + channel: nextChannel, + cooldownRemainingSeconds: nextCooldownRemainingSeconds, + t: nextT, + } = nextProps; const tEqual = prevT === nextT; if (!tEqual) { return false; } - const cooldownActiveEqual = prevCooldownActive === nextCooldownActive; - if (!cooldownActiveEqual) { + const cooldownRemainingSecondsEqual = + prevCooldownRemainingSeconds === nextCooldownRemainingSeconds; + if (!cooldownRemainingSecondsEqual) { return false; } @@ -206,6 +211,8 @@ const styles = StyleSheet.create({ flex: 1, fontSize: 16, includeFontPadding: false, // for android vertical text centering + lineHeight: 20, + paddingLeft: 16, paddingVertical: 12, textAlignVertical: 'center', // for android vertical text centering }, diff --git a/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js b/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js index c3591fc026..945581876e 100644 --- a/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js +++ b/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js @@ -113,29 +113,6 @@ describe('AutoCompleteInput', () => { }); }); - it('should style the text input with maxHeight that is set by the layout', async () => { - const channelProps = { channel }; - const props = { numberOfLines: 10 }; - - renderComponent({ channelProps, client, props }); - - const { queryByTestId } = screen; - - const input = queryByTestId('auto-complete-text-input'); - - act(() => { - fireEvent(input, 'contentSizeChange', { - nativeEvent: { - contentSize: { height: 100 }, - }, - }); - }); - - await waitFor(() => { - expect(input.props.style[1].maxHeight).toBe(1000); - }); - }); - it('should call the textComposer setSelection when the onSelectionChange is triggered', async () => { const { textComposer } = channel.messageComposer; @@ -166,12 +143,12 @@ describe('AutoCompleteInput', () => { // TODO: Add a test for command it.each([ - { cooldownActive: false, result: 'Send a message' }, - { cooldownActive: true, result: 'Slow mode ON' }, + { cooldownRemainingSeconds: undefined, result: 'Send a message' }, + { cooldownRemainingSeconds: 10, result: 'Slow mode, wait 10s...' }, ])('should have the placeholderText as Slow mode ON when cooldown is active', async (data) => { const channelProps = { channel }; const props = { - cooldownActive: data.cooldownActive, + cooldownRemainingSeconds: data.cooldownRemainingSeconds, }; renderComponent({ channelProps, client, props }); diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 6afeff2d77..fe0469bd88 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -173,9 +173,7 @@ import { MessageTimestamp as MessageTimestampDefault } from '../Message/MessageS import { ReactionListBottom as ReactionListBottomDefault } from '../Message/MessageSimple/ReactionList/ReactionListBottom'; import { ReactionListTop as ReactionListTopDefault } from '../Message/MessageSimple/ReactionList/ReactionListTop'; import { StreamingMessageView as DefaultStreamingMessageView } from '../Message/MessageSimple/StreamingMessageView'; -import { AttachButton as AttachButtonDefault } from '../MessageInput/AttachButton'; -import { AttachmentUploadPreviewList as AttachmentUploadPreviewDefault } from '../MessageInput/AttachmentUploadPreviewList'; -import { CommandsButton as CommandsButtonDefault } from '../MessageInput/CommandsButton'; +import { AttachmentUploadPreviewList as AttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList'; import { AttachmentUploadProgressIndicator as AttachmentUploadProgressIndicatorDefault } from '../MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator'; import { AudioAttachmentUploadPreview as AudioAttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview'; import { FileAttachmentUploadPreview as FileAttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview'; @@ -187,12 +185,10 @@ import { AudioRecordingLockIndicator as AudioRecordingLockIndicatorDefault } fro import { AudioRecordingPreview as AudioRecordingPreviewDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingPreview'; import { AudioRecordingWaveform as AudioRecordingWaveformDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingWaveform'; import { CommandInput as CommandInputDefault } from '../MessageInput/components/CommandInput'; -import { InputEditingStateHeader as InputEditingStateHeaderDefault } from '../MessageInput/components/InputEditingStateHeader'; -import { InputReplyStateHeader as InputReplyStateHeaderDefault } from '../MessageInput/components/InputReplyStateHeader'; -import { CooldownTimer as CooldownTimerDefault } from '../MessageInput/CooldownTimer'; -import { InputButtons as InputButtonsDefault } from '../MessageInput/InputButtons'; -import { MoreOptionsButton as MoreOptionsButtonDefault } from '../MessageInput/MoreOptionsButton'; -import { SendButton as SendButtonDefault } from '../MessageInput/SendButton'; +import { InputButtons as InputButtonsDefault } from '../MessageInput/components/InputButtons'; +import { AttachButton as AttachButtonDefault } from '../MessageInput/components/InputButtons/AttachButton'; +import { CooldownTimer as CooldownTimerDefault } from '../MessageInput/components/OutputButtons/CooldownTimer'; +import { SendButton as SendButtonDefault } from '../MessageInput/components/OutputButtons/SendButton'; import { SendMessageDisallowedIndicator as SendMessageDisallowedIndicatorDefault } from '../MessageInput/SendMessageDisallowedIndicator'; import { ShowThreadMessageInChannelButton as ShowThreadMessageInChannelButtonDefault } from '../MessageInput/ShowThreadMessageInChannelButton'; import { StopMessageStreamingButton as DefaultStopMessageStreamingButton } from '../MessageInput/StopMessageStreamingButton'; @@ -608,7 +604,6 @@ const ChannelWithContext = (props: PropsWithChildren) = channel, children, client, - CommandsButton = CommandsButtonDefault, compressImageQuality, CooldownTimer = CooldownTimerDefault, CreatePollContent, @@ -671,9 +666,7 @@ const ChannelWithContext = (props: PropsWithChildren) = InlineUnreadIndicator = InlineUnreadIndicatorDefault, Input, InputButtons = InputButtonsDefault, - InputEditingStateHeader = InputEditingStateHeaderDefault, CommandInput = CommandInputDefault, - InputReplyStateHeader = InputReplyStateHeaderDefault, isAttachmentEqual, isMessageAIGenerated = () => false, keyboardBehavior, @@ -708,6 +701,7 @@ const ChannelWithContext = (props: PropsWithChildren) = MessageDeleted = MessageDeletedDefault, MessageEditedTimestamp = MessageEditedTimestampDefault, MessageError = MessageErrorDefault, + messageInputFloating = false, MessageFooter = MessageFooterDefault, MessageHeader, messageId, @@ -729,7 +723,6 @@ const ChannelWithContext = (props: PropsWithChildren) = MessageUserReactions = MessageUserReactionsDefault, MessageUserReactionsAvatar = MessageUserReactionsAvatarDefault, MessageUserReactionsItem = MessageUserReactionsItemDefault, - MoreOptionsButton = MoreOptionsButtonDefault, myMessageTheme, NetworkDownIndicator = NetworkDownIndicatorDefault, // TODO: Think about this one @@ -1866,7 +1859,6 @@ const ChannelWithContext = (props: PropsWithChildren) = CameraSelectorIcon, channelId, CommandInput, - CommandsButton, compressImageQuality, CooldownTimer, CreatePollContent, @@ -1884,9 +1876,7 @@ const ChannelWithContext = (props: PropsWithChildren) = ImageSelectorIcon, Input, InputButtons, - InputEditingStateHeader, - InputReplyStateHeader, - MoreOptionsButton, + messageInputFloating, openPollCreationDialog, SendButton, sendMessage, diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts index 5c5d4a0607..197d1419b8 100644 --- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts +++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts @@ -30,7 +30,6 @@ export const useCreateInputMessageInputContext = ({ channelId, CameraSelectorIcon, CommandInput, - CommandsButton, compressImageQuality, CooldownTimer, CreatePollContent, @@ -48,9 +47,7 @@ export const useCreateInputMessageInputContext = ({ ImageSelectorIcon, Input, InputButtons, - InputEditingStateHeader, - InputReplyStateHeader, - MoreOptionsButton, + messageInputFloating, openPollCreationDialog, SendButton, sendMessage, @@ -96,7 +93,6 @@ export const useCreateInputMessageInputContext = ({ AutoCompleteSuggestionList, CameraSelectorIcon, CommandInput, - CommandsButton, compressImageQuality, CooldownTimer, CreatePollContent, @@ -114,9 +110,7 @@ export const useCreateInputMessageInputContext = ({ ImageSelectorIcon, Input, InputButtons, - InputEditingStateHeader, - InputReplyStateHeader, - MoreOptionsButton, + messageInputFloating, openPollCreationDialog, SendButton, sendMessage, diff --git a/package/src/components/Message/MessageSimple/MessageContent.tsx b/package/src/components/Message/MessageSimple/MessageContent.tsx index bddd71b580..7f02d27310 100644 --- a/package/src/components/Message/MessageSimple/MessageContent.tsx +++ b/package/src/components/Message/MessageSimple/MessageContent.tsx @@ -27,8 +27,6 @@ import { useTranslationContext, } from '../../../contexts/translationContext/TranslationContext'; -import { useViewport } from '../../../hooks/useViewport'; - import { checkMessageEquality, checkQuotedMessageEquality } from '../../../utils/utils'; import { Poll } from '../../Poll/Poll'; import { useMessageData } from '../hooks/useMessageData'; @@ -180,7 +178,6 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { }, }, } = useTheme(); - const { vw } = useViewport(); const onLayout: (event: LayoutChangeEvent) => void = ({ nativeEvent: { @@ -325,7 +322,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { key={`quoted_reply_${messageContentOrderIndex}`} style={[styles.replyContainer, replyContainer]} > - + ) ); diff --git a/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx deleted file mode 100644 index d14f51a2be..0000000000 --- a/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { FlatList, LayoutChangeEvent, StyleSheet, View } from 'react-native'; - -import { - isLocalAudioAttachment, - isLocalFileAttachment, - isLocalImageAttachment, - isLocalVoiceRecordingAttachment, - isVideoAttachment, - LocalAttachment, - LocalImageAttachment, -} from 'stream-chat'; - -import { useMessageComposer } from '../../contexts'; -import { useAttachmentManagerState } from '../../contexts/messageInputContext/hooks/useAttachmentManagerState'; -import { - MessageInputContextValue, - useMessageInputContext, -} from '../../contexts/messageInputContext/MessageInputContext'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { isSoundPackageAvailable } from '../../native'; - -const IMAGE_PREVIEW_SIZE = 100; -const FILE_PREVIEW_HEIGHT = 60; - -export type AttachmentUploadPreviewListPropsWithContext = Pick< - MessageInputContextValue, - | 'AudioAttachmentUploadPreview' - | 'FileAttachmentUploadPreview' - | 'ImageAttachmentUploadPreview' - | 'VideoAttachmentUploadPreview' ->; - -/** - * AttachmentUploadPreviewList - * UI Component to preview the files set for upload - */ -const UnMemoizedAttachmentUploadListPreview = ( - props: AttachmentUploadPreviewListPropsWithContext, -) => { - const [flatListWidth, setFlatListWidth] = useState(0); - const flatListRef = useRef | null>(null); - const { - AudioAttachmentUploadPreview, - FileAttachmentUploadPreview, - ImageAttachmentUploadPreview, - VideoAttachmentUploadPreview, - } = props; - const { attachmentManager } = useMessageComposer(); - const { attachments } = useAttachmentManagerState(); - const { - theme: { - colors: { grey_whisper }, - messageInput: { - attachmentSeparator, - attachmentUploadPreviewList: { filesFlatList, imagesFlatList, wrapper }, - }, - }, - } = useTheme(); - - const imageUploads = attachments.filter((attachment) => isLocalImageAttachment(attachment)); - const fileUploads = useMemo(() => { - return attachments.filter((attachment) => !isLocalImageAttachment(attachment)); - }, [attachments]); - - const renderImageItem = useCallback( - ({ item }: { item: LocalImageAttachment }) => { - return ( - - ); - }, - [ - ImageAttachmentUploadPreview, - attachmentManager.removeAttachments, - attachmentManager.uploadAttachment, - ], - ); - - const renderFileItem = useCallback( - ({ item }: { item: LocalAttachment }) => { - if (isLocalImageAttachment(item)) { - // This is already handled in the `renderImageItem` above, so we return null here to avoid duplication. - return null; - } else if (isLocalVoiceRecordingAttachment(item)) { - return ( - - ); - } else if (isLocalAudioAttachment(item)) { - if (isSoundPackageAvailable()) { - return ( - - ); - } else { - return ( - - ); - } - } else if (isVideoAttachment(item)) { - return ( - - ); - } else if (isLocalFileAttachment(item)) { - return ( - - ); - } else return null; - }, - [ - AudioAttachmentUploadPreview, - FileAttachmentUploadPreview, - VideoAttachmentUploadPreview, - attachmentManager.removeAttachments, - attachmentManager.uploadAttachment, - flatListWidth, - ], - ); - - useEffect(() => { - if (fileUploads.length && flatListRef.current) { - setTimeout(() => flatListRef.current?.scrollToEnd(), 1); - } - }, [fileUploads.length]); - - const onLayout = useCallback( - (event: LayoutChangeEvent) => { - if (flatListRef.current) { - setFlatListWidth(event.nativeEvent.layout.width); - } - }, - [flatListRef], - ); - - if (!attachments.length) { - return null; - } - - return ( - - {imageUploads.length ? ( - ({ - index, - length: IMAGE_PREVIEW_SIZE + 8, - offset: (IMAGE_PREVIEW_SIZE + 8) * index, - })} - horizontal - keyExtractor={(item) => item.localMetadata.id} - renderItem={renderImageItem} - style={[styles.imagesFlatList, imagesFlatList]} - /> - ) : null} - {imageUploads.length && fileUploads.length ? ( - - ) : null} - {fileUploads.length ? ( - ({ - index, - length: FILE_PREVIEW_HEIGHT + 8, - offset: (FILE_PREVIEW_HEIGHT + 8) * index, - })} - keyExtractor={(item) => item.localMetadata.id} - onLayout={onLayout} - ref={flatListRef} - renderItem={renderFileItem} - style={[styles.filesFlatList, filesFlatList]} - testID={'file-upload-preview'} - /> - ) : null} - - ); -}; - -export type AttachmentUploadPreviewListProps = Partial; - -const MemoizedAttachmentUploadPreviewListWithContext = React.memo( - UnMemoizedAttachmentUploadListPreview, -); - -/** - * AttachmentUploadPreviewList - * UI Component to preview the files set for upload - */ -export const AttachmentUploadPreviewList = (props: AttachmentUploadPreviewListProps) => { - const { - AudioAttachmentUploadPreview, - FileAttachmentUploadPreview, - ImageAttachmentUploadPreview, - VideoAttachmentUploadPreview, - } = useMessageInputContext(); - return ( - - ); -}; - -const styles = StyleSheet.create({ - attachmentSeparator: { - borderBottomWidth: 1, - marginVertical: 8, - }, - filesFlatList: { maxHeight: FILE_PREVIEW_HEIGHT * 2.5 + 16 }, - imagesFlatList: {}, - wrapper: { - paddingTop: 12, - }, -}); - -AttachmentUploadPreviewList.displayName = - 'AttachmentUploadPreviewList{messageInput{attachmentUploadPreviewList}}'; diff --git a/package/src/components/MessageInput/CommandsButton.tsx b/package/src/components/MessageInput/CommandsButton.tsx deleted file mode 100644 index 7ca093dece..0000000000 --- a/package/src/components/MessageInput/CommandsButton.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { useCallback } from 'react'; -import type { GestureResponderEvent, PressableProps } from 'react-native'; -import { Pressable } from 'react-native'; - -import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { Lightning } from '../../icons/Lightning'; - -export type CommandsButtonProps = { - /** Function that opens commands selector. */ - handleOnPress?: PressableProps['onPress']; -}; - -export const CommandsButton = (props: CommandsButtonProps) => { - const { handleOnPress } = props; - const messageComposer = useMessageComposer(); - const { textComposer } = messageComposer; - - const onPressHandler = useCallback( - async (event: GestureResponderEvent) => { - if (handleOnPress) { - handleOnPress(event); - return; - } - - await textComposer.handleChange({ - selection: { - end: 1, - start: 1, - }, - text: '/', - }); - }, - [handleOnPress, textComposer], - ); - - const { - theme: { - colors: { grey }, - messageInput: { commandsButton }, - }, - } = useTheme(); - - return ( - - - - ); -}; - -CommandsButton.displayName = 'CommandsButton{messageInput}'; diff --git a/package/src/components/MessageInput/CooldownTimer.tsx b/package/src/components/MessageInput/CooldownTimer.tsx deleted file mode 100644 index 36c524d2db..0000000000 --- a/package/src/components/MessageInput/CooldownTimer.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import { StyleSheet, Text, View } from 'react-native'; - -import { useTheme } from '../../contexts/themeContext/ThemeContext'; - -export type CooldownTimerProps = { - seconds: number; -}; - -const CONTAINER_SIZE = 24; -const CONTAINER_HORIZONTAL_PADDING = 6; -const EXTRA_CHARACTER_PADDING = CONTAINER_SIZE - CONTAINER_HORIZONTAL_PADDING * 2; - -/** - * To avoid the container jumping between sizes when there are more - * than one character in the width of the container since we aren't - * using a monospaced font. - */ -const normalizeWidth = (seconds: number) => - CONTAINER_SIZE + EXTRA_CHARACTER_PADDING * (`${seconds}`.length - 1); - -/** - * Renders an amount of seconds left for a cooldown to finish. - * - * See `useCountdown` for an example of how to set a countdown - * to use as the source of `seconds`. - **/ -export const CooldownTimer = (props: CooldownTimerProps) => { - const { seconds } = props; - const { - theme: { - colors: { black, grey_gainsboro }, - messageInput: { - cooldownTimer: { container, text }, - }, - }, - } = useTheme(); - - return ( - - - {seconds} - - - ); -}; - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - borderRadius: CONTAINER_SIZE / 2, - height: CONTAINER_SIZE, - justifyContent: 'center', - minWidth: CONTAINER_SIZE, - paddingHorizontal: CONTAINER_HORIZONTAL_PADDING, - }, - text: { fontSize: 16, fontWeight: '600' }, -}); diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 37e594f47e..5c6080e790 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Modal, StyleSheet, TextInput, TextInputProps, View } from 'react-native'; +import React, { useEffect } from 'react'; +import { Modal, Platform, StyleSheet, TextInput, TextInputProps, View } from 'react-native'; import { Gesture, @@ -9,19 +9,30 @@ import { } from 'react-native-gesture-handler'; import Animated, { Extrapolation, + FadeIn, + FadeOut, interpolate, + LinearTransition, runOnJS, useAnimatedStyle, useSharedValue, withSpring, + ZoomIn, + ZoomOut, } from 'react-native-reanimated'; import { type MessageComposerState, type TextComposerState, type UserResponse } from 'stream-chat'; +import { OutputButtons } from './components/OutputButtons'; import { useAudioRecorder } from './hooks/useAudioRecorder'; import { useCountdown } from './hooks/useCountdown'; -import { ChatContextValue, useChatContext, useOwnCapabilitiesContext } from '../../contexts'; +import { + ChatContextValue, + useAttachmentManagerState, + useChatContext, + useOwnCapabilitiesContext, +} from '../../contexts'; import { AttachmentPickerContextValue, useAttachmentPickerContext, @@ -34,9 +45,7 @@ import { MessageComposerAPIContextValue, useMessageComposerAPIContext, } from '../../contexts/messageComposerContext/MessageComposerAPIContext'; -import { useAttachmentManagerState } from '../../contexts/messageInputContext/hooks/useAttachmentManagerState'; import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; -import { useMessageComposerHasSendableData } from '../../contexts/messageInputContext/hooks/useMessageComposerHasSendableData'; import { MessageInputContextValue, useMessageInputContext, @@ -52,47 +61,75 @@ import { useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; +import { useKeyboardVisibility } from '../../hooks/useKeyboardVisibility'; import { useStateStore } from '../../hooks/useStateStore'; import { isAudioRecorderAvailable, NativeHandlers } from '../../native'; -import { AIStates, useAIState } from '../AITypingIndicatorView'; +import { + MessageInputHeightState, + messageInputHeightStore, + setMessageInputHeight, +} from '../../state-store/message-input-height-store'; import { AutoCompleteInput } from '../AutoCompleteInput/AutoCompleteInput'; import { CreatePoll } from '../Poll/CreatePollContent'; import { SafeAreaViewWrapper } from '../UIComponents/SafeAreaViewWrapper'; const styles = StyleSheet.create({ - attachmentSeparator: { - borderBottomWidth: 1, - marginBottom: 10, - }, - autoCompleteInputContainer: { - alignItems: 'center', - flexDirection: 'row', - }, - composerContainer: { + container: { alignItems: 'center', flexDirection: 'row', + gap: 8, justifyContent: 'space-between', }, - container: { - borderTopWidth: 1, - padding: 10, + contentContainer: { + gap: 4, + overflow: 'hidden', + paddingHorizontal: 8, + }, + floatingWrapper: { + left: 0, + paddingHorizontal: 24, + position: 'absolute', + right: 0, }, inputBoxContainer: { - borderRadius: 20, + flex: 1, + }, + inputBoxWrapper: { + borderRadius: 24, borderWidth: 1, flex: 1, - marginHorizontal: 10, + flexDirection: 'row', }, - micButtonContainer: {}, - optionsContainer: { + inputButtonsContainer: { + alignSelf: 'flex-end', + }, + inputContainer: { + alignItems: 'center', flexDirection: 'row', + justifyContent: 'space-between', + }, + micButtonContainer: {}, + outputButtonsContainer: { + alignSelf: 'flex-end', + padding: 8, + }, + shadow: { + elevation: 6, + + shadowColor: 'hsla(0, 0%, 0%, 0.24)', + shadowOffset: { height: 4, width: 0 }, + shadowOpacity: 0.24, + shadowRadius: 12, }, - replyContainer: { paddingBottom: 0, paddingHorizontal: 8, paddingTop: 8 }, - sendButtonContainer: {}, suggestionsListContainer: { position: 'absolute', width: '100%', }, + wrapper: { + borderTopWidth: 1, + paddingHorizontal: 24, + paddingTop: 24, + }, }); type MessageInputPropsWithContext = Pick< @@ -125,14 +162,13 @@ type MessageInputPropsWithContext = Pick< | 'Input' | 'inputBoxRef' | 'InputButtons' - | 'InputEditingStateHeader' | 'CameraSelectorIcon' | 'CreatePollIcon' | 'FileSelectorIcon' + | 'messageInputFloating' | 'ImageSelectorIcon' | 'VideoRecorderSelectorIcon' | 'CommandInput' - | 'InputReplyStateHeader' | 'SendButton' | 'ShowThreadMessageInChannelButton' | 'StartAudioRecordingButton' @@ -148,6 +184,8 @@ type MessageInputPropsWithContext = Pick< Pick & Pick & { editing: boolean; + hasAttachments: boolean; + isKeyboardVisible: boolean; TextInputComponent?: React.ComponentType< TextInputProps & { ref: React.Ref | undefined; @@ -157,7 +195,6 @@ type MessageInputPropsWithContext = Pick< const textComposerStateSelector = (state: TextComposerState) => ({ command: state.command, - hasText: !!state.text, mentionedUsers: state.mentionedUsers, suggestions: state.suggestions, }); @@ -166,6 +203,10 @@ const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ quotedMessage: state.quotedMessage, }); +const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ + height: state.height, +}); + const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const { AttachmentPickerSelectionBar, @@ -186,58 +227,59 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { AudioRecordingLockIndicator, AudioRecordingPreview, AutoCompleteSuggestionList, - channel, closeAttachmentPicker, closePollCreationDialog, cooldownEndsAt, - CooldownTimer, CreatePollContent, disableAttachmentPicker, editing, + hasAttachments, + messageInputFloating, Input, inputBoxRef, InputButtons, - InputEditingStateHeader, CommandInput, - InputReplyStateHeader, + isKeyboardVisible, isOnline, members, Reply, threadList, - SendButton, sendMessage, showPollCreationDialog, ShowThreadMessageInChannelButton, StartAudioRecordingButton, - StopMessageStreamingButton, TextInputComponent, watchers, } = props; const messageComposer = useMessageComposer(); + const { clearEditingState } = useMessageComposerAPIContext(); + const onDismissEditMessage = () => { + clearEditingState(); + }; const { textComposer } = messageComposer; - const { command, hasText } = useStateStore(textComposer.state, textComposerStateSelector); + const { command } = useStateStore(textComposer.state, textComposerStateSelector); const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector); - const { attachments } = useAttachmentManagerState(); - const hasSendableData = useMessageComposerHasSendableData(); - - const [height, setHeight] = useState(0); + const { height } = useStateStore(messageInputHeightStore, messageInputHeightStoreSelector); const { theme: { colors: { border, grey_whisper, white, white_smoke }, messageInput: { attachmentSelectionBar, - autoCompleteInputContainer, - composerContainer, container, + contentContainer, + floatingWrapper, focusedInputBoxContainer, inputBoxContainer, + inputBoxWrapper, + inputContainer, + inputButtonsContainer, + inputFloatingContainer, micButtonContainer, - optionsContainer, - replyContainer, - sendButtonContainer, + outputButtonsContainer, suggestionsListContainer: { container: suggestionListContainer }, + wrapper, }, }, } = useTheme(); @@ -323,11 +365,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { } = useAudioRecorder(); const asyncAudioEnabled = audioRecordingEnabled && isAudioRecorderAvailable(); - const showSendingButton = hasText || attachments.length || command; - - const isSendingButtonVisible = useMemo(() => { - return asyncAudioEnabled ? showSendingButton && !recording : true; - }, [asyncAudioEnabled, recording, showSendingButton]); const micPositionX = useSharedValue(0); const micPositionY = useSharedValue(0); @@ -418,24 +455,31 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { ], })); - const { aiState } = useAIState(channel); - - const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]); - const shouldDisplayStopAIGeneration = - [AIStates.Thinking, AIStates.Generating].includes(aiState) && !!StopMessageStreamingButton; + const BOTTOM_OFFSET = isKeyboardVisible ? 24 : Platform.OS === 'ios' ? 32 : 24; return ( <> - setHeight(newHeight)} - style={[styles.container, { backgroundColor: white, borderColor: border }, container]} + }) => setMessageInputHeight(messageInputFloating ? newHeight + BOTTOM_OFFSET : newHeight)} // 24 is the position of the input from the bottom of the screen + style={ + messageInputFloating + ? [styles.floatingWrapper, { bottom: BOTTOM_OFFSET }, floatingWrapper] + : [ + styles.wrapper, + { + backgroundColor: white, + borderColor: border, + paddingBottom: BOTTOM_OFFSET, + }, + wrapper, + ] + } > - {editing && } - {quotedMessage && !editing && } {recording && ( <> { )} - + {Input ? ( ) : ( @@ -480,76 +524,118 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { /> ) : ( <> - + {InputButtons && } - - {quotedMessage && ( - - + + + {editing ? ( + + + + ) : null} + {quotedMessage ? ( + + + + ) : null} + - )} - - - {command ? ( - - ) : ( - - + + + {command ? ( + + ) : ( + + )} + + + + - )} - + + )} - {shouldDisplayStopAIGeneration ? ( - - ) : isSendingButtonVisible ? ( - cooldownRemainingSeconds ? ( - - ) : ( - - - - ) - ) : null} - {audioRecordingEnabled && isAudioRecorderAvailable() && !micLocked && ( + {asyncAudioEnabled && !micLocked ? ( - - + + + + - )} + ) : null} )} - + - + - + {!disableAttachmentPicker && selectedPicker ? ( - { ]} > - + ) : null} {showPollCreationDialog ? ( @@ -600,6 +686,8 @@ const areEqual = ( closePollCreationDialog: prevClosePollCreationDialog, cooldownEndsAt: prevCooldownEndsAt, editing: prevEditing, + hasAttachments: prevHasAttachments, + isKeyboardVisible: prevIsKeyboardVisible, isOnline: prevIsOnline, openPollCreationDialog: prevOpenPollCreationDialog, selectedPicker: prevSelectedPicker, @@ -617,7 +705,9 @@ const areEqual = ( closePollCreationDialog: nextClosePollCreationDialog, cooldownEndsAt: nextCooldownEndsAt, editing: nextEditing, + isKeyboardVisible: nextIsKeyboardVisible, isOnline: nextIsOnline, + hasAttachments: nextHasAttachments, openPollCreationDialog: nextOpenPollCreationDialog, selectedPicker: nextSelectedPicker, showPollCreationDialog: nextShowPollCreationDialog, @@ -677,6 +767,16 @@ const areEqual = ( return false; } + const hasAttachmentsEqual = prevHasAttachments === nextHasAttachments; + if (!hasAttachmentsEqual) { + return false; + } + + const isKeyboardVisibleEqual = prevIsKeyboardVisible === nextIsKeyboardVisible; + if (!isKeyboardVisibleEqual) { + return false; + } + const isOnlineEqual = prevIsOnline === nextIsOnline; if (!isOnlineEqual) { return false; @@ -754,9 +854,8 @@ export const MessageInput = (props: MessageInputProps) => { Input, inputBoxRef, InputButtons, - InputEditingStateHeader, CommandInput, - InputReplyStateHeader, + messageInputFloating, openPollCreationDialog, SendButton, sendMessage, @@ -775,6 +874,8 @@ export const MessageInput = (props: MessageInputProps) => { const { clearEditingState } = useMessageComposerAPIContext(); const { Reply } = useMessagesContext(); + const { attachments } = useAttachmentManagerState(); + const isKeyboardVisible = useKeyboardVisibility(); const { t } = useTranslationContext(); @@ -823,14 +924,15 @@ export const MessageInput = (props: MessageInputProps) => { disableAttachmentPicker, editing, FileSelectorIcon, + hasAttachments: attachments.length > 0, ImageSelectorIcon, Input, inputBoxRef, InputButtons, - InputEditingStateHeader, - InputReplyStateHeader, + isKeyboardVisible, isOnline, members, + messageInputFloating, openPollCreationDialog, Reply, selectedPicker, diff --git a/package/src/components/MessageInput/MoreOptionsButton.tsx b/package/src/components/MessageInput/MoreOptionsButton.tsx deleted file mode 100644 index f02227da61..0000000000 --- a/package/src/components/MessageInput/MoreOptionsButton.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { Pressable } from 'react-native'; - -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { CircleRight } from '../../icons/CircleRight'; - -export type MoreOptionsButtonProps = { - /** Function that opens attachment options bottom sheet */ - handleOnPress?: () => void; -}; - -export const MoreOptionsButton = (props: MoreOptionsButtonProps) => { - const { handleOnPress } = props; - - const { - theme: { - colors: { accent_blue }, - messageInput: { moreOptionsButton }, - }, - } = useTheme(); - - return ( - [moreOptionsButton, { opacity: pressed ? 0.8 : 1 }]} - testID='more-options-button' - > - - - ); -}; - -MoreOptionsButton.displayName = 'MoreOptionsButton{messageInput}'; diff --git a/package/src/components/MessageInput/SendButton.tsx b/package/src/components/MessageInput/SendButton.tsx deleted file mode 100644 index b5b9959cf4..0000000000 --- a/package/src/components/MessageInput/SendButton.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React, { useCallback } from 'react'; - -import { Pressable } from 'react-native'; - -import { TextComposerState } from 'stream-chat'; - -import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; -import { - MessageInputContextValue, - useMessageInputContext, -} from '../../contexts/messageInputContext/MessageInputContext'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { useStateStore } from '../../hooks/useStateStore'; -import { Search } from '../../icons/Search'; -import { SendRight } from '../../icons/SendRight'; -import { SendUp } from '../../icons/SendUp'; - -export type SendButtonProps = Partial> & { - /** Disables the button */ - disabled: boolean; -}; - -const textComposerStateSelector = (state: TextComposerState) => ({ - command: state.command, -}); - -export const SendButton = (props: SendButtonProps) => { - const { disabled = false, sendMessage: propsSendMessage } = props; - const { sendMessage: sendMessageFromContext } = useMessageInputContext(); - const sendMessage = propsSendMessage || sendMessageFromContext; - const messageComposer = useMessageComposer(); - const { textComposer } = messageComposer; - const { command } = useStateStore(textComposer.state, textComposerStateSelector); - - const { - theme: { - colors: { accent_blue, grey_gainsboro }, - messageInput: { searchIcon, sendButton, sendRightIcon, sendUpIcon }, - }, - } = useTheme(); - - const onPressHandler = useCallback(() => { - if (disabled) { - return; - } - sendMessage(); - }, [disabled, sendMessage]); - - return ( - - {command ? ( - - ) : disabled ? ( - - ) : ( - - )} - - ); -}; - -SendButton.displayName = 'SendButton{messageInput}'; diff --git a/package/src/components/MessageInput/__tests__/AttachButton.test.js b/package/src/components/MessageInput/__tests__/AttachButton.test.js index d0201770aa..dbd9ae7305 100644 --- a/package/src/components/MessageInput/__tests__/AttachButton.test.js +++ b/package/src/components/MessageInput/__tests__/AttachButton.test.js @@ -8,7 +8,7 @@ import { initiateClientWithChannels } from '../../../mock-builders/api/initiateC import * as NativeHandler from '../../../native'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; -import { AttachButton } from '../AttachButton'; +import { AttachButton } from '../components/InputButtons/AttachButton'; const renderComponent = ({ channelProps, client, props }) => { return render( diff --git a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js index 5532989480..91c86b386a 100644 --- a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js +++ b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js @@ -14,7 +14,7 @@ import { import { FileState } from '../../../utils/utils'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; -import { AttachmentUploadPreviewList } from '../AttachmentUploadPreviewList'; +import { AttachmentUploadPreviewList } from '../components/AttachmentPreview/AttachmentUploadPreviewList'; jest.mock('../../../native.ts', () => { const { View } = require('react-native'); diff --git a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js index 00a93050cb..4aebfdb773 100644 --- a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js +++ b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js @@ -9,7 +9,7 @@ import { generateAudioAttachment } from '../../../mock-builders/attachments'; import { FileState } from '../../../utils/utils'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; -import { AttachmentUploadPreviewList } from '../AttachmentUploadPreviewList'; +import { AttachmentUploadPreviewList } from '../components/AttachmentPreview/AttachmentUploadPreviewList'; jest.mock('../../../native.ts', () => { const View = require('react-native').View; diff --git a/package/src/components/MessageInput/__tests__/CommandsButton.test.js b/package/src/components/MessageInput/__tests__/CommandsButton.test.js deleted file mode 100644 index cd226cb652..0000000000 --- a/package/src/components/MessageInput/__tests__/CommandsButton.test.js +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react'; - -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; - -import { OverlayProvider } from '../../../contexts'; - -import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; -import { Channel } from '../../Channel/Channel'; -import { Chat } from '../../Chat/Chat'; -import { CommandsButton } from '../CommandsButton'; - -const renderComponent = ({ client, channel, props }) => { - return render( - - - - - - - , - ); -}; - -describe('CommandsButton', () => { - let client; - let channel; - - beforeEach(async () => { - const { client: chatClient, channels } = await initiateClientWithChannels(); - client = chatClient; - channel = channels[0]; - }); - - afterEach(() => { - jest.clearAllMocks(); - cleanup(); - }); - - it('should render component', async () => { - const props = {}; - renderComponent({ channel, client, props }); - - const { queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('commands-button')).toBeTruthy(); - }); - }); - - it('should call handleOnPress callback when the button is clicked if passed', async () => { - const handleOnPress = jest.fn(); - const props = { handleOnPress }; - - renderComponent({ channel, client, props }); - - const { getByTestId, queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('commands-button')).toBeTruthy(); - }); - - act(() => { - fireEvent.press(getByTestId('commands-button')); - }); - - await waitFor(() => { - expect(handleOnPress).toHaveBeenCalled(); - }); - }); -}); diff --git a/package/src/components/MessageInput/__tests__/InputButtons.test.js b/package/src/components/MessageInput/__tests__/InputButtons.test.js index 501fb72dba..d25e38492d 100644 --- a/package/src/components/MessageInput/__tests__/InputButtons.test.js +++ b/package/src/components/MessageInput/__tests__/InputButtons.test.js @@ -1,15 +1,13 @@ import React from 'react'; -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react-native'; import { OverlayProvider } from '../../../contexts'; import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; -import { generateImageAttachment } from '../../../mock-builders/attachments'; -import { FileState } from '../../../utils/utils'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; -import { InputButtons } from '../InputButtons'; +import { InputButtons } from '../components/InputButtons/index'; const renderComponent = ({ channelProps, client, props }) => { return render( @@ -42,24 +40,6 @@ describe('InputButtons', () => { }); }); - // TODO: Add it back once the command inject PR is merged - it.skip('should return null if the commands are set on the textComposer', async () => { - const props = {}; - const channelProps = { channel }; - - channel.messageComposer.textComposer.setCommand({ description: 'Ban a user', name: 'ban' }); - - renderComponent({ channelProps, client, props }); - - const { queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('more-options-button')).toBeFalsy(); - expect(queryByTestId('commands-button')).toBeFalsy(); - expect(queryByTestId('attach-button')).toBeFalsy(); - }); - }); - it('should return null if hasCommands is false and hasAttachmentUploadCapabilities is false', async () => { const props = {}; const channelProps = { @@ -75,28 +55,11 @@ describe('InputButtons', () => { const { queryByTestId } = screen; await waitFor(() => { - expect(queryByTestId('more-options-button')).toBeFalsy(); expect(queryByTestId('commands-button')).toBeFalsy(); expect(queryByTestId('attach-button')).toBeFalsy(); }); }); - it('should show more options when the hasCommand is true and the hasAttachmentUploadCapabilities is true', async () => { - const props = {}; - const channelProps = { - channel, - }; - - renderComponent({ channelProps, client, props }); - - const { queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('commands-button')).toBeTruthy(); - expect(queryByTestId('attach-button')).toBeTruthy(); - }); - }); - it('should show only attach button when the hasCommand is false and the hasAttachmentUploadCapabilities is true', async () => { const props = {}; const channelProps = { @@ -114,25 +77,6 @@ describe('InputButtons', () => { }); }); - it('should show only commands button when the hasCommand is true and the hasAttachmentUploadCapabilities is false', async () => { - const props = {}; - const channelProps = { - channel, - overrideOwnCapabilities: { - uploadFile: false, - }, - }; - - renderComponent({ channelProps, client, props }); - - const { queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('commands-button')).toBeTruthy(); - expect(queryByTestId('attach-button')).toBeFalsy(); - }); - }); - it('should not show commands buttons when there is text in the textComposer', async () => { const props = {}; const channelProps = { @@ -146,62 +90,4 @@ describe('InputButtons', () => { expect(queryByTestId('commands-button')).toBeFalsy(); }); }); - - it('should show more options button when there is text in the textComposer', async () => { - const props = {}; - const channelProps = { - channel, - }; - channel.messageComposer.textComposer.setText('hello'); - - renderComponent({ channelProps, client, props }); - - const { queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('more-options-button')).toBeTruthy(); - }); - - act(() => { - fireEvent.press(queryByTestId('more-options-button')); - }); - - await waitFor(() => { - // Falsy, because the textComposer has text. This is a good test. - expect(queryByTestId('commands-button')).toBeFalsy(); - expect(queryByTestId('attach-button')).toBeTruthy(); - }); - }); - - it('should show more options button when there is attachments', async () => { - const props = {}; - const channelProps = { - channel, - }; - channel.messageComposer.attachmentManager.upsertAttachments([ - generateImageAttachment({ - localMetadata: { - id: 'image-attachment', - uploadState: FileState.UPLOADING, - }, - }), - ]); - - renderComponent({ channelProps, client, props }); - - const { queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('more-options-button')).toBeTruthy(); - }); - - act(() => { - fireEvent.press(queryByTestId('more-options-button')); - }); - - await waitFor(() => { - expect(queryByTestId('commands-button')).toBeTruthy(); - expect(queryByTestId('attach-button')).toBeTruthy(); - }); - }); }); diff --git a/package/src/components/MessageInput/__tests__/MessageInput.test.js b/package/src/components/MessageInput/__tests__/MessageInput.test.js index 634472fb1a..8cea3bef40 100644 --- a/package/src/components/MessageInput/__tests__/MessageInput.test.js +++ b/package/src/components/MessageInput/__tests__/MessageInput.test.js @@ -9,7 +9,6 @@ import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvide import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; -import { NativeHandlers } from '../../../native'; import { AttachmentPickerSelectionBar } from '../../AttachmentPicker/components/AttachmentPickerSelectionBar'; import { CameraSelectorIcon } from '../../AttachmentPicker/components/CameraSelectorIcon'; import { FileSelectorIcon } from '../../AttachmentPicker/components/FileSelectorIcon'; @@ -90,59 +89,60 @@ describe('MessageInput', () => { }); }); - it('should start the audio recorder on long press and cleanup on unmount', async () => { - renderComponent({ - channelProps: { audioRecordingEnabled: true, channel }, - client, - props: {}, - }); - - const { queryByTestId, unmount } = screen; - - const audioButton = queryByTestId('audio-button'); - - act(() => { - fireEvent(audioButton, 'longPress'); - }); - - await waitFor(() => { - expect(NativeHandlers.Audio.startRecording).toHaveBeenCalledTimes(1); - expect(NativeHandlers.Audio.stopRecording).not.toHaveBeenCalled(); - expect(queryByTestId('recording-active-container')).toBeTruthy(); - expect(Alert.alert).not.toHaveBeenCalledWith('Hold to start recording.'); - }); - - await act(() => { - unmount(); - }); - - await waitFor(() => { - expect(NativeHandlers.Audio.stopRecording).toHaveBeenCalledTimes(1); - }); - }); - - it('should trigger an alert if a normal press happened on audio recording', async () => { - renderComponent({ - channelProps: { audioRecordingEnabled: true, channel }, - client, - props: {}, - }); - - const { queryByTestId } = screen; - - const audioButton = queryByTestId('audio-button'); - - act(() => { - fireEvent.press(audioButton); - }); - - await waitFor(() => { - expect(NativeHandlers.Audio.startRecording).not.toHaveBeenCalled(); - expect(NativeHandlers.Audio.stopRecording).not.toHaveBeenCalled(); - expect(queryByTestId('recording-active-container')).not.toBeTruthy(); - // This is sort of a brittle test, but there doesn't seem to be another way - // to target alerts. The reason why it's here is because we had a bug with it. - expect(Alert.alert).toHaveBeenCalledWith('Hold to start recording.'); - }); - }); + // TODO: Once the async audio design is done, fix it + // it('should start the audio recorder on long press and cleanup on unmount', async () => { + // renderComponent({ + // channelProps: { audioRecordingEnabled: false, channel }, // TODO: Once the async audio design is done, fix it + // client, + // props: {}, + // }); + + // const { getByLabelText, unmount } = screen; + + // const audioButton = getByLabelText('Start recording'); + + // act(() => { + // fireEvent(audioButton, 'longPress'); + // }); + + // await waitFor(() => { + // expect(NativeHandlers.Audio.startRecording).toHaveBeenCalledTimes(1); + // expect(NativeHandlers.Audio.stopRecording).not.toHaveBeenCalled(); + // expect(queryByTestId('recording-active-container')).toBeTruthy(); + // expect(Alert.alert).not.toHaveBeenCalledWith('Hold to start recording.'); + // }); + + // await act(() => { + // unmount(); + // }); + + // await waitFor(() => { + // expect(NativeHandlers.Audio.stopRecording).toHaveBeenCalledTimes(1); + // }); + // }); + + // it('should trigger an alert if a normal press happened on audio recording', async () => { + // renderComponent({ + // channelProps: { audioRecordingEnabled: false, channel }, + // client, + // props: {}, + // }); + + // const { getByLabelText, queryByTestId } = screen; + + // const audioButton = getByLabelText('Start recording'); + + // act(() => { + // fireEvent.press(audioButton); + // }); + + // await waitFor(() => { + // expect(NativeHandlers.Audio.startRecording).not.toHaveBeenCalled(); + // expect(NativeHandlers.Audio.stopRecording).not.toHaveBeenCalled(); + // expect(queryByTestId('recording-active-container')).not.toBeTruthy(); + // // This is sort of a brittle test, but there doesn't seem to be another way + // // to target alerts. The reason why it's here is because we had a bug with it. + // expect(Alert.alert).toHaveBeenCalledWith('Hold to start recording.'); + // }); + // }); }); diff --git a/package/src/components/MessageInput/__tests__/SendButton.test.js b/package/src/components/MessageInput/__tests__/SendButton.test.js index e501588eb6..f237aad828 100644 --- a/package/src/components/MessageInput/__tests__/SendButton.test.js +++ b/package/src/components/MessageInput/__tests__/SendButton.test.js @@ -7,7 +7,7 @@ import { OverlayProvider } from '../../../contexts'; import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; -import { SendButton } from '../SendButton'; +import { SendButton } from '../components/OutputButtons/SendButton'; const renderComponent = ({ client, channel, props }) => { return render( @@ -60,7 +60,6 @@ describe('SendButton', () => { await waitFor(() => { expect(sendMessage).toHaveBeenCalledTimes(1); - expect(getByTestId('send-up')).toBeDefined(); }); const snapshot = toJSON(); @@ -90,7 +89,6 @@ describe('SendButton', () => { await waitFor(() => { expect(sendMessage).toHaveBeenCalledTimes(0); - expect(getByTestId('send-right')).toBeDefined(); }); const snapshot = toJSON(); @@ -99,21 +97,4 @@ describe('SendButton', () => { expect(snapshot).toMatchSnapshot(); }); }); - - // TODO: Add it back once the command inject PR is merged - it.skip('should show search button if the command is enabled', async () => { - const sendMessage = jest.fn(); - - const props = { sendMessage }; - - channel.messageComposer.textComposer.setCommand({ description: 'Ban a user', name: 'ban' }); - - renderComponent({ channel, client, props }); - - const { queryByTestId } = screen; - - await waitFor(() => { - expect(queryByTestId('search-icon')).toBeTruthy(); - }); - }); }); diff --git a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap index ce8d93e13d..8c62fffa57 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap @@ -51,6 +51,20 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli onStartShouldSetResponder={[Function]} style={ [ + { + "alignItems": "center", + "justifyContent": "center", + }, + { + "borderRadius": 24, + "height": 48, + "width": 48, + }, + { + "backgroundColor": "#FFFFFF", + "borderColor": "#E2E6EA", + "borderWidth": 1, + }, {}, ] } @@ -58,14 +72,17 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli > - - - - - - - - + } + strokeLinecap={1} + strokeLinejoin={1} + strokeWidth={1.5} + /> @@ -401,6 +398,20 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` onStartShouldSetResponder={[Function]} style={ [ + { + "alignItems": "center", + "justifyContent": "center", + }, + { + "borderRadius": 24, + "height": 48, + "width": 48, + }, + { + "backgroundColor": "#FFFFFF", + "borderColor": "#E2E6EA", + "borderWidth": 1, + }, {}, ] } @@ -408,14 +419,17 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` > - - - - - - - - + } + strokeLinecap={1} + strokeLinejoin={1} + strokeWidth={1.5} + /> @@ -751,6 +745,20 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` onStartShouldSetResponder={[Function]} style={ [ + { + "alignItems": "center", + "justifyContent": "center", + }, + { + "borderRadius": 24, + "height": 48, + "width": 48, + }, + { + "backgroundColor": "#FFFFFF", + "borderColor": "#E2E6EA", + "borderWidth": 1, + }, {}, ] } @@ -758,14 +766,17 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` > - - - - - - - - + } + strokeLinecap={1} + strokeLinejoin={1} + strokeWidth={1.5} + /> diff --git a/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap b/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap index 193309cc73..9ba722b90a 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap @@ -50,6 +50,20 @@ exports[`SendButton should render a SendButton 1`] = ` onStartShouldSetResponder={[Function]} style={ [ + { + "alignItems": "center", + "justifyContent": "center", + }, + { + "borderRadius": 16, + "height": 32, + "width": 32, + }, + { + "backgroundColor": "#005FFF", + "borderColor": "#E2E6EA", + "borderWidth": 1, + }, {}, ] } @@ -57,14 +71,18 @@ exports[`SendButton should render a SendButton 1`] = ` > - - @@ -375,6 +394,20 @@ exports[`SendButton should render a disabled SendButton 1`] = ` onStartShouldSetResponder={[Function]} style={ [ + { + "alignItems": "center", + "justifyContent": "center", + }, + { + "borderRadius": 16, + "height": 32, + "width": 32, + }, + { + "backgroundColor": "#005FFF", + "borderColor": "#E2E6EA", + "borderWidth": 1, + }, {}, ] } @@ -382,14 +415,18 @@ exports[`SendButton should render a disabled SendButton 1`] = ` > - - diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator.tsx index 6ff419c2f3..096d293824 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator.tsx @@ -3,11 +3,9 @@ import { StyleSheet, Text, View } from 'react-native'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; -import { Warning } from '../../../../icons/Warning'; +import { ErrorCircle } from '../../../../icons/ErrorCircle'; import { Progress, ProgressIndicatorTypes } from '../../../../utils/utils'; -const WARNING_ICON_SIZE = 16; - export type AttachmentUnsupportedIndicatorProps = { /** Type of active indicator */ indicatorType?: Progress; @@ -21,7 +19,7 @@ export const AttachmentUnsupportedIndicator = ({ }: AttachmentUnsupportedIndicatorProps) => { const { theme: { - colors: { accent_red, grey_dark, overlay, white }, + colors: { accent_error, overlay }, messageInput: { attachmentUnsupportedIndicator: { container, text, warningIcon }, }, @@ -42,16 +40,14 @@ export const AttachmentUnsupportedIndicator = ({ container, ]} > - - - {t('Not supported')} - + {t('Not supported')} ); }; @@ -61,7 +57,6 @@ const styles = StyleSheet.create({ alignItems: 'center', flexDirection: 'row', marginTop: 4, - paddingHorizontal: 2, }, imageStyle: { borderRadius: 16, @@ -70,13 +65,11 @@ const styles = StyleSheet.create({ }, warningIconStyle: { borderRadius: 24, - marginTop: 6, }, warningText: { alignItems: 'center', color: 'black', - fontSize: 10, - justifyContent: 'center', + fontSize: 12, marginHorizontal: 4, }, }); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx new file mode 100644 index 0000000000..c10db5e1e0 --- /dev/null +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx @@ -0,0 +1,245 @@ +import React, { useCallback } from 'react'; +import { FlatList, StyleSheet, View } from 'react-native'; + +import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; + +import { + isLocalAudioAttachment, + isLocalFileAttachment, + isLocalImageAttachment, + isLocalVoiceRecordingAttachment, + isVideoAttachment, + LocalAttachment, +} from 'stream-chat'; + +import { useMessageComposer } from '../../../../contexts'; +import { useAttachmentManagerState } from '../../../../contexts/messageInputContext/hooks/useAttachmentManagerState'; +import { + MessageInputContextValue, + useMessageInputContext, +} from '../../../../contexts/messageInputContext/MessageInputContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { isSoundPackageAvailable } from '../../../../native'; + +const IMAGE_PREVIEW_SIZE = 100; +const FILE_PREVIEW_HEIGHT = 60; + +export type AttachmentUploadListPreviewPropsWithContext = Pick< + MessageInputContextValue, + | 'AudioAttachmentUploadPreview' + | 'FileAttachmentUploadPreview' + | 'ImageAttachmentUploadPreview' + | 'VideoAttachmentUploadPreview' +>; + +const ItemSeparatorComponent = () => { + const { + theme: { + messageInput: { + attachmentUploadPreviewList: { itemSeparator }, + }, + }, + } = useTheme(); + return ; +}; + +const getItemLayout = (data: ArrayLike | null | undefined, index: number) => { + const item = data?.[index]; + if (item && isLocalImageAttachment(item as LocalAttachment)) { + return { + index, + length: IMAGE_PREVIEW_SIZE + 8, + offset: (IMAGE_PREVIEW_SIZE + 8) * index, + }; + } + return { + index, + length: FILE_PREVIEW_HEIGHT + 8, + offset: (FILE_PREVIEW_HEIGHT + 8) * index, + }; +}; + +/** + * AttachmentUploadPreviewList + * UI Component to preview the files set for upload + */ +const UnMemoizedAttachmentUploadPreviewList = ( + props: AttachmentUploadListPreviewPropsWithContext, +) => { + const { + AudioAttachmentUploadPreview, + FileAttachmentUploadPreview, + ImageAttachmentUploadPreview, + VideoAttachmentUploadPreview, + } = props; + const { attachmentManager } = useMessageComposer(); + const { attachments } = useAttachmentManagerState(); + const { + theme: { + messageInput: { + attachmentUploadPreviewList: { flatList }, + }, + }, + } = useTheme(); + + const renderItem = useCallback( + ({ item }: { item: LocalAttachment }) => { + if (isLocalImageAttachment(item)) { + return ( + + + + ); + } else if (isLocalVoiceRecordingAttachment(item)) { + return ( + + + + ); + } else if (isLocalAudioAttachment(item)) { + if (isSoundPackageAvailable()) { + return ( + + + + ); + } else { + return ( + + + + ); + } + } else if (isVideoAttachment(item)) { + return ( + + + + ); + } else if (isLocalFileAttachment(item)) { + return ( + + + + ); + } else return null; + }, + [ + AudioAttachmentUploadPreview, + FileAttachmentUploadPreview, + ImageAttachmentUploadPreview, + VideoAttachmentUploadPreview, + attachmentManager.removeAttachments, + attachmentManager.uploadAttachment, + ], + ); + + if (!attachments.length) { + return null; + } + + return ( + item.localMetadata.id} + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + style={[styles.flatList, flatList]} + testID={'attachment-upload-preview-list'} + /> + ); +}; + +export type AttachmentUploadPreviewListProps = Partial; + +const MemoizedAttachmentUploadPreviewListWithContext = React.memo( + UnMemoizedAttachmentUploadPreviewList, +); + +/** + * AttachmentUploadPreviewList + * UI Component to preview the files set for upload + */ +export const AttachmentUploadPreviewList = (props: AttachmentUploadPreviewListProps) => { + const { + AudioAttachmentUploadPreview, + FileAttachmentUploadPreview, + ImageAttachmentUploadPreview, + VideoAttachmentUploadPreview, + } = useMessageInputContext(); + return ( + + ); +}; + +const styles = StyleSheet.create({ + flatList: { + overflow: 'visible', + }, + itemSeparator: { + width: 8, + }, + wrapper: {}, +}); + +AttachmentUploadPreviewList.displayName = + 'AttachmentUploadPreviewList{messageInput{attachmentUploadPreviewList}}'; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx b/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx index 43158d4b92..631bd8efd8 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx @@ -3,14 +3,14 @@ import React from 'react'; import { Pressable, PressableProps, StyleSheet } from 'react-native'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; -import { Close } from '../../../../icons'; +import { NewClose } from '../../../../icons/NewClose'; type DismissAttachmentUploadProps = PressableProps; export const DismissAttachmentUpload = ({ onPress }: DismissAttachmentUploadProps) => { const { theme: { - colors: { overlay, white }, + colors: { white }, messageInput: { dismissAttachmentUpload: { dismiss, dismissIcon, dismissIconColor }, }, @@ -22,21 +22,24 @@ export const DismissAttachmentUpload = ({ onPress }: DismissAttachmentUploadProp onPress={onPress} style={({ pressed }) => [ styles.dismiss, - { backgroundColor: overlay, opacity: pressed ? 0.8 : 1 }, + { + borderColor: white, + opacity: pressed ? 0.8 : 1, + }, dismiss, ]} testID='remove-upload-preview' > - + ); }; const styles = StyleSheet.create({ dismiss: { - borderRadius: 24, - position: 'absolute', - right: 8, - top: 8, + backgroundColor: '#384047', + borderRadius: 16, + borderWidth: 2, + overflow: 'hidden', }, }); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx index 856bc31e87..9e913ad692 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx @@ -14,7 +14,6 @@ import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; import { useMessagesContext } from '../../../../contexts/messagesContext/MessagesContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; -import { getTrimmedAttachmentTitle } from '../../../../utils/getTrimmedAttachmentTitle'; import { getDurationLabelFromDuration, getIndicatorTypeForFileState, @@ -26,13 +25,10 @@ export type FileAttachmentUploadPreviewProps | LocalVideoAttachment | LocalAudioAttachment - > & { - flatListWidth: number; - }; + >; export const FileAttachmentUploadPreview = ({ attachment, - flatListWidth, handleRetry, removeAttachments, }: FileAttachmentUploadPreviewProps) => { @@ -71,7 +67,7 @@ export const FileAttachmentUploadPreview = ({ - + - + - {getTrimmedAttachmentTitle(attachment.title)} + {attachment.title} {indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? ( @@ -120,17 +110,26 @@ export const FileAttachmentUploadPreview = ({ - + + + ); }; const styles = StyleSheet.create({ + dismissWrapper: { position: 'absolute', right: 0, top: 0 }, fileContainer: { borderRadius: 12, borderWidth: 1, flexDirection: 'row', - paddingHorizontal: 8, + gap: 12, + maxWidth: 224, // TODO: Not sure how to omit this + padding: 16, + }, + fileContent: { + flexShrink: 1, + justifyContent: 'space-between', }, fileIcon: { alignItems: 'center', @@ -138,24 +137,19 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, filenameText: { - fontSize: 14, - fontWeight: 'bold', + fontSize: 12, + fontWeight: '600', + }, + fileNameTextContainer: { + flexShrink: 1, }, fileSizeText: { fontSize: 12, - marginTop: 10, - }, - fileTextContainer: { - justifyContent: 'space-around', - marginVertical: 10, - paddingHorizontal: 10, }, overlay: { borderRadius: 12, - marginTop: 2, }, wrapper: { - flexDirection: 'row', - marginHorizontal: 8, + padding: 4, }, }); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx index 7c29cc5715..c476a9b330 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx @@ -13,7 +13,7 @@ import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; import { getIndicatorTypeForFileState, ProgressIndicatorTypes } from '../../../../utils/utils'; -const IMAGE_PREVIEW_SIZE = 100; +const IMAGE_PREVIEW_SIZE = 72; export type ImageAttachmentUploadPreviewProps> = UploadAttachmentPreviewProps>; @@ -32,7 +32,7 @@ export const ImageAttachmentUploadPreview = ({ const { theme: { messageInput: { - imageAttachmentUploadPreview: { itemContainer, upload }, + imageAttachmentUploadPreview: { container, upload, wrapper }, }, }, } = useTheme(); @@ -54,10 +54,10 @@ export const ImageAttachmentUploadPreview = ({ }, []); return ( - + + {indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? ( + + ) : null} - - {indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? ( - - ) : null} + + + ); }; const styles = StyleSheet.create({ + container: { + borderColor: '#E2E6EA', + borderRadius: 12, + borderWidth: 1, + flexDirection: 'row', + overflow: 'hidden', + }, + dismissWrapper: { position: 'absolute', right: 0, top: 0 }, fileSizeText: { fontSize: 12, paddingHorizontal: 10, }, - flatList: { paddingBottom: 12 }, - itemContainer: { - flexDirection: 'row', - height: IMAGE_PREVIEW_SIZE, - marginLeft: 8, - }, upload: { - borderRadius: 10, height: IMAGE_PREVIEW_SIZE, width: IMAGE_PREVIEW_SIZE, }, + wrapper: { + padding: 4, + }, }); diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx index 0186f0d571..39782431dd 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import { Alert, Linking, Pressable, StyleSheet } from 'react-native'; +import { Alert, Linking } from 'react-native'; +import { IconButton } from '../../../../components/ui/IconButton'; import { MessageInputContextValue, useMessageInputContext, } from '../../../../contexts/messageInputContext/MessageInputContext'; -import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; -import { Mic } from '../../../../icons/Mic'; +import { NewMic } from '../../../../icons/NewMic'; import { AudioRecordingReturnType, NativeHandlers } from '../../../../native'; export type AudioRecordingButtonProps = Partial< @@ -45,7 +45,6 @@ export type AudioRecordingButtonProps = Partial< export const AudioRecordingButton = (props: AudioRecordingButtonProps) => { const { asyncMessagesMinimumPressDuration: propAsyncMessagesMinimumPressDuration, - buttonSize, handleLongPress, handlePress, permissionsGranted, @@ -58,14 +57,6 @@ export const AudioRecordingButton = (props: AudioRecordingButtonProps) => { const asyncMessagesMinimumPressDuration = propAsyncMessagesMinimumPressDuration || contextAsyncMessagesMinimumPressDuration; - const { - theme: { - colors: { grey, light_gray, white }, - messageInput: { - audioRecordingButton: { container, micIcon }, - }, - }, - } = useTheme(); const { t } = useTranslationContext(); const onPressHandler = () => { @@ -103,33 +94,17 @@ export const AudioRecordingButton = (props: AudioRecordingButtonProps) => { }; return ( - [ - styles.container, - { - backgroundColor: pressed ? light_gray : white, - height: buttonSize || 40, - width: buttonSize || 40, - }, - container, - ]} - testID='audio-button' - > - - + size='sm' + type='secondary' + /> ); }; -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - borderRadius: 50, - justifyContent: 'center', - marginLeft: 8, - }, -}); - AudioRecordingButton.displayName = 'AudioRecordingButton{messageInput}'; diff --git a/package/src/components/MessageInput/components/CommandInput.tsx b/package/src/components/MessageInput/components/CommandInput.tsx index c789f657f1..f8c6c58f8b 100644 --- a/package/src/components/MessageInput/components/CommandInput.tsx +++ b/package/src/components/MessageInput/components/CommandInput.tsx @@ -43,8 +43,8 @@ export const CommandInput = ({ theme: { colors: { accent_blue, grey, white }, messageInput: { - autoCompleteInputContainer, commandInput: { closeButton, container, text }, + inputContainer, }, }, } = useTheme(); @@ -61,13 +61,13 @@ export const CommandInput = ({ const commandName = (command.name ?? '').toUpperCase(); return ( - + {commandName} - + { } = props; const { theme: { - colors: { accent_blue, grey }, messageInput: { attachButton }, }, } = useTheme(); @@ -84,15 +82,16 @@ const AttachButtonWithContext = (props: AttachButtonPropsWithContext) => { return ( <> - - - + type='secondary' + /> {showAttachButtonPicker ? ( ; export type InputButtonsWithContextProps = Pick< MessageInputContextValue, | 'AttachButton' - | 'CommandsButton' | 'hasCameraPicker' | 'hasCommands' | 'hasFilePicker' | 'hasImagePicker' - | 'MoreOptionsButton' | 'toggleAttachmentPicker' > & Pick & Pick; -const textComposerStateSelector = (state: TextComposerState) => ({ - command: state.command, - hasText: !!state.text, -}); - export const InputButtonsWithContext = (props: InputButtonsWithContextProps) => { const { AttachButton, - CommandsButton, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, - MoreOptionsButton, uploadFile: ownCapabilitiesUploadFile, } = props; - const { textComposer } = useMessageComposer(); - const { command, hasText } = useStateStore(textComposer.state, textComposerStateSelector); - - const [showMoreOptions, setShowMoreOptions] = useState(true); - const { attachments } = useAttachmentManagerState(); - - const shouldShowMoreOptions = hasText || attachments.length; - - useEffect(() => { - setShowMoreOptions(!shouldShowMoreOptions); - }, [shouldShowMoreOptions]); const { theme: { @@ -68,36 +43,18 @@ export const InputButtonsWithContext = (props: InputButtonsWithContextProps) => }, } = useTheme(); - const handleShowMoreOptions = useCallback(() => { - setShowMoreOptions(true); - }, [setShowMoreOptions]); - const hasAttachmentUploadCapabilities = (hasCameraPicker || hasFilePicker || hasImagePicker) && ownCapabilitiesUploadFile; - const showCommandsButton = hasCommands && !hasText; - - if (command) { - return null; - } if (!hasAttachmentUploadCapabilities && !hasCommands) { return null; } - return !showMoreOptions ? ( - - ) : ( - <> - {hasAttachmentUploadCapabilities ? ( - - - - ) : null} - {showCommandsButton ? : null} - - ); + return hasAttachmentUploadCapabilities ? ( + + + + ) : null; }; const areEqual = ( @@ -106,7 +63,6 @@ const areEqual = ( ) => { const { hasCameraPicker: prevHasCameraPicker, - hasCommands: prevHasCommands, hasFilePicker: prevHasFilePicker, hasImagePicker: prevHasImagePicker, selectedPicker: prevSelectedPicker, @@ -114,7 +70,6 @@ const areEqual = ( const { hasCameraPicker: nextHasCameraPicker, - hasCommands: nextHasCommands, hasFilePicker: nextHasFilePicker, hasImagePicker: nextHasImagePicker, selectedPicker: nextSelectedPicker, @@ -132,10 +87,6 @@ const areEqual = ( return false; } - if (prevHasCommands !== nextHasCommands) { - return false; - } - if (prevSelectedPicker !== nextSelectedPicker) { return false; } @@ -151,12 +102,10 @@ const MemoizedInputButtonsWithContext = React.memo( export const InputButtons = (props: InputButtonsProps) => { const { AttachButton, - CommandsButton, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, - MoreOptionsButton, toggleAttachmentPicker, } = useMessageInputContext(); const { selectedPicker } = useAttachmentPickerContext(); @@ -166,12 +115,10 @@ export const InputButtons = (props: InputButtonsProps) => { { }; const styles = StyleSheet.create({ - attachButtonContainer: { paddingRight: 5 }, + attachButtonContainer: {}, }); diff --git a/package/src/components/MessageInput/components/InputEditingStateHeader.tsx b/package/src/components/MessageInput/components/InputEditingStateHeader.tsx deleted file mode 100644 index cb05599899..0000000000 --- a/package/src/components/MessageInput/components/InputEditingStateHeader.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useCallback } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; - -import { - MessageComposerAPIContextValue, - useMessageComposerAPIContext, -} from '../../../contexts/messageComposerContext/MessageComposerAPIContext'; -import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; -import { useTheme } from '../../../contexts/themeContext/ThemeContext'; -import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; - -import { CircleClose, Edit } from '../../../icons'; - -export type InputEditingStateHeaderProps = Partial< - Pick ->; - -export const InputEditingStateHeader = ({ - clearEditingState: propClearEditingState, -}: InputEditingStateHeaderProps) => { - const messageComposer = useMessageComposer(); - const { t } = useTranslationContext(); - const { clearEditingState: contextClearEditingState } = useMessageComposerAPIContext(); - - const clearEditingState = propClearEditingState || contextClearEditingState; - - const { - theme: { - colors: { black, grey, grey_gainsboro }, - messageInput: { - editingStateHeader: { editingBoxHeader, editingBoxHeaderTitle }, - }, - }, - } = useTheme(); - - const onCloseHandler = useCallback(() => { - if (clearEditingState) { - clearEditingState(); - } - messageComposer.restore(); - }, [clearEditingState, messageComposer]); - - return ( - - - - {t('Editing Message')} - - [{ opacity: pressed ? 0.8 : 1 }]} - testID='close-button' - > - - - - ); -}; - -const styles = StyleSheet.create({ - editingBoxHeader: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - paddingBottom: 10, - }, - editingBoxHeaderTitle: { - fontSize: 14, - fontWeight: 'bold', - }, -}); - -InputEditingStateHeader.displayName = 'EditingStateHeader{messageInput}'; diff --git a/package/src/components/MessageInput/components/InputReplyStateHeader.tsx b/package/src/components/MessageInput/components/InputReplyStateHeader.tsx deleted file mode 100644 index 8fd09bf49d..0000000000 --- a/package/src/components/MessageInput/components/InputReplyStateHeader.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; - -import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; -import { useTheme } from '../../../contexts/themeContext/ThemeContext'; -import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; - -import { CircleClose, CurveLineLeftUp } from '../../../icons'; - -export const InputReplyStateHeader = () => { - const { t } = useTranslationContext(); - const messageComposer = useMessageComposer(); - const { - theme: { - colors: { black, grey, grey_gainsboro }, - messageInput: { - editingStateHeader: { editingBoxHeader, editingBoxHeaderTitle }, - }, - }, - } = useTheme(); - - const onCloseHandler = () => { - messageComposer.setQuotedMessage(null); - }; - - return ( - - - - {t('Reply to Message')} - - [{ opacity: pressed ? 0.8 : 1 }]} - testID='close-button' - > - - - - ); -}; - -const styles = StyleSheet.create({ - replyBoxHeader: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - paddingBottom: 8, - }, - replyBoxHeaderTitle: { - fontSize: 14, - fontWeight: 'bold', - }, -}); - -InputReplyStateHeader.displayName = 'ReplyStateHeader{messageInput}'; diff --git a/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx b/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx new file mode 100644 index 0000000000..ecf6105a2e --- /dev/null +++ b/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx @@ -0,0 +1,49 @@ +import React, { useCallback } from 'react'; +import { StyleSheet, Text } from 'react-native'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { IconButton } from '../../../ui/IconButton'; + +export type CooldownTimerProps = { + seconds: number; +}; + +/** + * Renders an amount of seconds left for a cooldown to finish. + * + * See `useCountdown` for an example of how to set a countdown + * to use as the source of `seconds`. + **/ +export const CooldownTimer = (props: CooldownTimerProps) => { + const { seconds } = props; + const { + theme: { + messageInput: { + cooldownTimer: { text }, + }, + }, + } = useTheme(); + + const icon = useCallback(() => { + return ( + + {seconds} + + ); + }, [seconds, text]); + + return ( + + ); +}; + +const styles = StyleSheet.create({ + text: { color: '#B8BEC4', fontSize: 16, fontWeight: '600' }, +}); diff --git a/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx b/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx new file mode 100644 index 0000000000..48adc924f5 --- /dev/null +++ b/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx @@ -0,0 +1,47 @@ +import React, { useCallback } from 'react'; + +import { + MessageInputContextValue, + useMessageInputContext, +} from '../../../../contexts/messageInputContext/MessageInputContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { NewTick } from '../../../../icons/NewTick'; +import { IconButton } from '../../../ui/IconButton'; + +export type EditButtonProps = Partial> & { + /** Disables the button */ + disabled: boolean; +}; + +export const EditButton = (props: EditButtonProps) => { + const { disabled = false, sendMessage: propsSendMessage } = props; + const { sendMessage: sendMessageFromContext } = useMessageInputContext(); + const sendMessage = propsSendMessage || sendMessageFromContext; + + const { + theme: { + messageInput: { editButton }, + }, + } = useTheme(); + + const onPressHandler = useCallback(() => { + if (disabled) { + return; + } + sendMessage(); + }, [disabled, sendMessage]); + + return ( + + ); +}; + +EditButton.displayName = 'EditButton{messageInput}'; diff --git a/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx b/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx new file mode 100644 index 0000000000..b0ccabad26 --- /dev/null +++ b/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx @@ -0,0 +1,47 @@ +import React, { useCallback } from 'react'; + +import { + MessageInputContextValue, + useMessageInputContext, +} from '../../../../contexts/messageInputContext/MessageInputContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { SendRight } from '../../../../icons/SendRight'; +import { IconButton } from '../../../ui/IconButton'; + +export type SendButtonProps = Partial> & { + /** Disables the button */ + disabled: boolean; +}; + +export const SendButton = (props: SendButtonProps) => { + const { disabled = false, sendMessage: propsSendMessage } = props; + const { sendMessage: sendMessageFromContext } = useMessageInputContext(); + const sendMessage = propsSendMessage || sendMessageFromContext; + + const { + theme: { + messageInput: { sendButton }, + }, + } = useTheme(); + + const onPressHandler = useCallback(() => { + if (disabled) { + return; + } + sendMessage(); + }, [disabled, sendMessage]); + + return ( + + ); +}; + +SendButton.displayName = 'SendButton{messageInput}'; diff --git a/package/src/components/MessageInput/components/OutputButtons/index.tsx b/package/src/components/MessageInput/components/OutputButtons/index.tsx new file mode 100644 index 0000000000..dda8a74d56 --- /dev/null +++ b/package/src/components/MessageInput/components/OutputButtons/index.tsx @@ -0,0 +1,202 @@ +import React, { useCallback } from 'react'; + +import Animated, { ZoomIn, ZoomOut } from 'react-native-reanimated'; + +import { TextComposerState } from 'stream-chat'; + +import { EditButton } from './EditButton'; + +import { + ChannelContextValue, + ChatContextValue, + useChannelContext, + useChatContext, + useMessageComposerHasSendableData, + useTheme, +} from '../../../../contexts'; +import { useAttachmentManagerState } from '../../../../contexts/messageInputContext/hooks/useAttachmentManagerState'; +import { useMessageComposer } from '../../../../contexts/messageInputContext/hooks/useMessageComposer'; +import { + MessageInputContextValue, + useMessageInputContext, +} from '../../../../contexts/messageInputContext/MessageInputContext'; +import { useStateStore } from '../../../../hooks/useStateStore'; +import { AIStates, useAIState } from '../../../AITypingIndicatorView'; +import { AudioRecordingButton } from '../../components/AudioRecorder/AudioRecordingButton'; +import { useCountdown } from '../../hooks/useCountdown'; + +export type OutputButtonsProps = Partial; + +export type OutputButtonsWithContextProps = Pick & + Pick & + Pick< + MessageInputContextValue, + | 'asyncMessagesMinimumPressDuration' + | 'asyncMessagesSlideToCancelDistance' + | 'asyncMessagesLockDistance' + | 'asyncMessagesMultiSendEnabled' + | 'audioRecordingEnabled' + | 'cooldownEndsAt' + | 'CooldownTimer' + | 'SendButton' + | 'StopMessageStreamingButton' + | 'StartAudioRecordingButton' + >; + +const textComposerStateSelector = (state: TextComposerState) => ({ + command: state.command, + hasText: !!state.text, +}); + +export const OutputButtonsWithContext = (props: OutputButtonsWithContextProps) => { + const { + channel, + cooldownEndsAt, + CooldownTimer, + isOnline, + SendButton, + StopMessageStreamingButton, + } = props; + const { + theme: { + messageInput: { + audioRecordingButtonContainer, + cooldownButtonContainer, + editButtonContainer, + sendButtonContainer, + }, + }, + } = useTheme(); + + const messageComposer = useMessageComposer(); + const editing = !!messageComposer.editedMessage; + const { textComposer } = messageComposer; + const { command, hasText } = useStateStore(textComposer.state, textComposerStateSelector); + const { attachments } = useAttachmentManagerState(); + const hasSendableData = useMessageComposerHasSendableData(); + + const showSendingButton = hasText || attachments.length || command; + + const { seconds: cooldownRemainingSeconds } = useCountdown(cooldownEndsAt); + + const { aiState } = useAIState(channel); + const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]); + const shouldDisplayStopAIGeneration = + [AIStates.Thinking, AIStates.Generating].includes(aiState) && !!StopMessageStreamingButton; + + if (shouldDisplayStopAIGeneration) { + return ; + } + + if (editing) { + return ( + + + + ); + } + + if (cooldownRemainingSeconds) { + return ( + + + + ); + } + + if (showSendingButton) { + return ( + + + + ); + } + + return ( + + + + ); +}; + +const areEqual = ( + prevProps: OutputButtonsWithContextProps, + nextProps: OutputButtonsWithContextProps, +) => { + const { channel: prevChannel, cooldownEndsAt: prevCooldownEndsAt } = prevProps; + + const { channel: nextChannel, cooldownEndsAt: nextCooldownEndsAt } = nextProps; + + if (prevChannel?.cid !== nextChannel?.cid) { + return false; + } + + const cooldownEndsAtEqual = prevCooldownEndsAt === nextCooldownEndsAt; + if (!cooldownEndsAtEqual) { + return false; + } + + return true; +}; + +const MemoizedOutputButtonsWithContext = React.memo( + OutputButtonsWithContext, + areEqual, +) as typeof OutputButtonsWithContext; + +export const OutputButtons = (props: OutputButtonsProps) => { + const { isOnline } = useChatContext(); + const { channel } = useChannelContext(); + const { + audioRecordingEnabled, + asyncMessagesMinimumPressDuration, + asyncMessagesSlideToCancelDistance, + asyncMessagesLockDistance, + asyncMessagesMultiSendEnabled, + cooldownEndsAt, + CooldownTimer, + SendButton, + StopMessageStreamingButton, + StartAudioRecordingButton, + } = useMessageInputContext(); + + return ( + + ); +}; diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index bac31e5441..02c618be86 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -8,6 +8,8 @@ import { ViewToken, } from 'react-native'; +import Animated, { LinearTransition } from 'react-native-reanimated'; + import type { FlashListProps, FlashListRef } from '@shopify/flash-list'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; @@ -30,6 +32,10 @@ import { ImageGalleryContextValue, useImageGalleryContext, } from '../../contexts/imageGalleryContext/ImageGalleryContext'; +import { + MessageInputContextValue, + useMessageInputContext, +} from '../../contexts/messageInputContext/MessageInputContext'; import { MessageListItemContextValue, MessageListItemProvider, @@ -49,7 +55,11 @@ import { import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; -import { useStableCallback } from '../../hooks'; +import { useStableCallback, useStateStore } from '../../hooks'; +import { + MessageInputHeightState, + messageInputHeightStore, +} from '../../state-store/message-input-height-store'; import { FileTypes } from '../../types/types'; import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; @@ -96,6 +106,10 @@ const getPreviousLastMessage = (messages: LocalMessage[], newMessage?: MessageRe return previousLastMessage; }; +const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ + height: state.height, +}); + type MessageFlashListPropsWithContext = Pick< AttachmentPickerContextValue, 'closePicker' | 'selectedPicker' | 'setSelectedPicker' @@ -124,6 +138,7 @@ type MessageFlashListPropsWithContext = Pick< | 'maximumMessageLimit' > & Pick & + Pick & Pick & Pick & Pick< @@ -287,6 +302,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => loadMoreThread, markRead, maximumMessageLimit, + messageInputFloating, myMessageTheme, readEvents, NetworkDownIndicator, @@ -312,6 +328,11 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } = props; const flashListRef = useRef | null>(null); + const { height: messageInputHeight } = useStateStore( + messageInputHeightStore, + messageInputHeightStoreSelector, + ); + const [hasMoved, setHasMoved] = useState(false); const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); @@ -339,7 +360,14 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const { colors: { white_snow }, - messageList: { container, contentContainer, listContainer }, + messageList: { + container, + contentContainer, + listContainer, + scrollToBottomButtonContainer, + stickyHeaderContainer, + unreadMessagesNotificationContainer, + }, } = theme; const myMessageThemeString = useMemo(() => JSON.stringify(myMessageTheme), [myMessageTheme]); @@ -935,8 +963,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const visibleLength = nativeEvent.layoutMeasurement.height; const contentLength = nativeEvent.contentSize.height; - // Show scrollToBottom button once scroll position goes beyond 150. - const isScrollAtStart = contentLength - visibleLength - offset < 150; + const isScrollAtStart = contentLength - visibleLength - offset < messageInputHeight; const notLatestSet = channel.state.messages !== channel.state.latestMessages; @@ -1025,8 +1052,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => ); const flatListContentContainerStyle = useMemo( - () => [styles.contentContainer, contentContainer], - [contentContainer], + () => [ + styles.contentContainer, + { paddingBottom: messageInputFloating ? messageInputHeight : 0 }, + contentContainer, + ], + [contentContainer, messageInputFloating, messageInputHeight], ); const currentListHeightRef = useRef(undefined); @@ -1102,7 +1133,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => /> )} - + {messageListLengthAfterUpdate && StickyHeader ? ( ) : null} @@ -1112,14 +1143,27 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => )} - + + + {isUnreadNotificationOpen && !threadList ? ( - + + + ) : null} ); @@ -1180,6 +1224,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { const { loadMore, loadMoreRecent } = usePaginatedMessageListContext(); const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); const { readEvents } = useOwnCapabilitiesContext(); + const { messageInputFloating } = useMessageInputContext(); return ( { markRead, maximumMessageLimit, Message, + messageInputFloating, MessageSystem, myMessageTheme, NetworkDownIndicator, @@ -1241,7 +1287,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { const styles = StyleSheet.create({ container: { - alignItems: 'center', flex: 1, width: '100%', }, @@ -1259,8 +1304,20 @@ const styles = StyleSheet.create({ flex: 1, width: '100%', }, - stickyHeader: { + scrollToBottomButtonContainer: { + bottom: 8, position: 'absolute', + right: 24, + }, + stickyHeaderContainer: { + left: 0, + position: 'absolute', + right: 0, top: 0, }, + unreadMessagesNotificationContainer: { + alignSelf: 'center', + position: 'absolute', + top: 8, + }, }); diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index ac6693a396..7960e11735 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -9,6 +9,8 @@ import { ViewToken, } from 'react-native'; +import Animated, { LinearTransition } from 'react-native-reanimated'; + import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; import { useMessageList } from './hooks/useMessageList'; @@ -32,6 +34,10 @@ import { ImageGalleryContextValue, useImageGalleryContext, } from '../../contexts/imageGalleryContext/ImageGalleryContext'; +import { + MessageInputContextValue, + useMessageInputContext, +} from '../../contexts/messageInputContext/MessageInputContext'; import { MessageListItemContextValue, MessageListItemProvider, @@ -52,6 +58,11 @@ import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext' import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { useStableCallback } from '../../hooks'; +import { useStateStore } from '../../hooks/useStateStore'; +import { + MessageInputHeightState, + messageInputHeightStore, +} from '../../state-store/message-input-height-store'; import { FileTypes } from '../../types/types'; import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; @@ -61,7 +72,6 @@ const WAIT_FOR_SCROLL_TIMEOUT = 0; const MAX_RETRIES_AFTER_SCROLL_FAILURE = 10; const styles = StyleSheet.create({ container: { - alignItems: 'center', flex: 1, width: '100%', }, @@ -79,10 +89,22 @@ const styles = StyleSheet.create({ flex: 1, width: '100%', }, - stickyHeader: { + scrollToBottomButtonContainer: { + bottom: 8, + position: 'absolute', + right: 24, + }, + stickyHeaderContainer: { + left: 0, position: 'absolute', + right: 0, top: 0, }, + unreadMessagesNotificationContainer: { + alignSelf: 'center', + position: 'absolute', + top: 8, + }, }); const keyExtractor = (item: LocalMessage) => { @@ -160,6 +182,7 @@ type MessageListPropsWithContext = Pick< | 'TypingIndicatorContainer' | 'UnreadMessagesNotification' > & + Pick & Pick< ThreadContextValue, 'loadMoreRecentThread' | 'loadMoreThread' | 'thread' | 'threadInstance' @@ -236,6 +259,10 @@ const renderItem = ({ item: message }: { item: LocalMessage }) => { return ; }; +const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ + height: state.height, +}); + /** * The message list component renders a list of messages. It consumes the following contexts: * @@ -276,6 +303,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { loadMoreThread, markRead, maximumMessageLimit, + messageInputFloating, myMessageTheme, NetworkDownIndicator, noGroupByUser, @@ -301,10 +329,21 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } = props; const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); const { theme } = useTheme(); + const { height: messageInputHeight } = useStateStore( + messageInputHeightStore, + messageInputHeightStoreSelector, + ); const { colors: { white_snow }, - messageList: { container, contentContainer, listContainer }, + messageList: { + container, + contentContainer, + listContainer, + stickyHeaderContainer, + scrollToBottomButtonContainer, + unreadMessagesNotificationContainer, + }, } = theme; const myMessageThemeString = useMemo(() => JSON.stringify(myMessageTheme), [myMessageTheme]); @@ -915,8 +954,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const handleScroll: ScrollViewProps['onScroll'] = useStableCallback((event) => { const messageListHasMessages = processedMessageList.length > 0; const offset = event.nativeEvent.contentOffset.y; - // Show scrollToBottom button once scroll position goes beyond 150. - const isScrollAtBottom = offset <= 150; + const isScrollAtBottom = offset <= messageInputHeight; const notLatestSet = channel.state.messages !== channel.state.latestMessages; @@ -1122,11 +1160,16 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const flatListContentContainerStyle = useMemo( () => [ - styles.contentContainer, + { paddingTop: messageInputFloating ? messageInputHeight : 0 }, + additionalFlatListProps?.contentContainerStyle, + contentContainer, + ], + [ additionalFlatListProps?.contentContainerStyle, contentContainer, + messageInputHeight, + messageInputFloating, ], - [additionalFlatListProps?.contentContainerStyle, contentContainer], ); if (!FlatList) { @@ -1191,7 +1234,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { /> )} - + {messageListLengthAfterUpdate && StickyHeader ? ( ) : null} @@ -1201,14 +1244,30 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { )} - + {scrollToBottomButtonVisible ? ( + + + + ) : null} + {isUnreadNotificationOpen && !threadList ? ( - + + + ) : null} ); @@ -1261,6 +1320,7 @@ export const MessageList = (props: MessageListProps) => { TypingIndicatorContainer, UnreadMessagesNotification, } = useMessagesContext(); + const { messageInputFloating } = useMessageInputContext(); const { loadMore, loadMoreRecent } = usePaginatedMessageListContext(); const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); @@ -1294,6 +1354,7 @@ export const MessageList = (props: MessageListProps) => { markRead, maximumMessageLimit, Message, + messageInputFloating, MessageSystem, myMessageTheme, NetworkDownIndicator, diff --git a/package/src/components/MessageList/ScrollToBottomButton.tsx b/package/src/components/MessageList/ScrollToBottomButton.tsx index 8c15cb851c..5e7c732a70 100644 --- a/package/src/components/MessageList/ScrollToBottomButton.tsx +++ b/package/src/components/MessageList/ScrollToBottomButton.tsx @@ -1,29 +1,13 @@ import React from 'react'; -import { GestureResponderEvent, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { StyleSheet, Text, View } from 'react-native'; + +import Animated, { ZoomIn, ZoomOut } from 'react-native-reanimated'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { Down } from '../../icons'; +import { NewDown } from '../../icons/NewDown'; +import { IconButton } from '../ui/IconButton'; const styles = StyleSheet.create({ - container: { - alignItems: 'center', - borderRadius: 20, - elevation: 5, - height: 40, - justifyContent: 'center', - shadowOffset: { - height: 2, - width: 0, - }, - shadowOpacity: 0.25, - shadowRadius: 4, - width: 40, - }, - touchable: { - bottom: 20, - position: 'absolute', - right: 20, - }, unreadCountNotificationContainer: { alignItems: 'center', borderRadius: 10, @@ -40,16 +24,11 @@ const styles = StyleSheet.create({ textAlign: 'center', textAlignVertical: 'center', }, - wrapper: { - alignItems: 'center', - height: 50, - justifyContent: 'flex-end', - }, }); export type ScrollToBottomButtonProps = { /** onPress handler */ - onPress: (event: GestureResponderEvent) => void; + onPress: () => void; /** If we should show the notification or not */ showNotification?: boolean; unreadCount?: number; @@ -60,15 +39,12 @@ export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { const { theme: { - colors: { accent_blue, black, white }, + colors: { accent_blue, white }, messageList: { scrollToBottomButton: { - chevronColor, container, - touchable, unreadCountNotificationContainer, unreadCountNotificationText, - wrapper, }, }, }, @@ -79,37 +55,42 @@ export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { } return ( - - - - - - {!!unreadCount && ( - + + + + {!!unreadCount && ( + + - - {unreadCount} - - - )} - - + {unreadCount} + + + )} + ); }; diff --git a/package/src/components/MessageList/UnreadMessagesNotification.tsx b/package/src/components/MessageList/UnreadMessagesNotification.tsx index 47192ee192..0872d8be42 100644 --- a/package/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/package/src/components/MessageList/UnreadMessagesNotification.tsx @@ -4,7 +4,7 @@ import { Pressable, StyleSheet, Text } from 'react-native'; import { useChannelContext } from '../../contexts/channelContext/ChannelContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; -import { Close } from '../../icons'; +import { NewClose } from '../../icons/NewClose'; export type UnreadMessagesNotificationProps = { /** @@ -76,7 +76,7 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp closeButtonContainer, ]} > - + ); @@ -84,13 +84,11 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp const styles = StyleSheet.create({ container: { - alignItems: 'center', borderRadius: 20, elevation: 4, flexDirection: 'row', paddingHorizontal: 16, paddingVertical: 8, - position: 'absolute', shadowColor: '#000', shadowOffset: { height: 2, @@ -98,7 +96,6 @@ const styles = StyleSheet.create({ }, shadowOpacity: 0.23, shadowRadius: 2.62, - top: 8, }, text: { fontWeight: '500', diff --git a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap index 286fdbf9a2..eddcde61d4 100644 --- a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap +++ b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap @@ -1,136 +1,141 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`ScrollToBottomButton should render the message notification and match snapshot 1`] = ` - - - - - - - + } + strokeLinecap={1} + strokeLinejoin={1} + strokeWidth={1.5} + /> + + `; diff --git a/package/src/components/RTLComponents/WritingDirectionAwareText.tsx b/package/src/components/RTLComponents/WritingDirectionAwareText.tsx index 0e7e0fa17b..e34ff46d31 100644 --- a/package/src/components/RTLComponents/WritingDirectionAwareText.tsx +++ b/package/src/components/RTLComponents/WritingDirectionAwareText.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { I18nManager, StyleSheet, Text, ViewProps } from 'react-native'; +import { I18nManager, StyleSheet, Text, TextProps } from 'react-native'; const styles = StyleSheet.create({ defaultStyle: { writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr' }, }); -export type WritingDirectionAwareTextProps = ViewProps; +export type WritingDirectionAwareTextProps = TextProps; export const WritingDirectionAwareText = (props: WritingDirectionAwareTextProps) => { const { children, style, ...rest } = props; diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index 1ad146941e..764345705d 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -1,405 +1,474 @@ -import React, { useMemo, useState } from 'react'; - -import { Image, ImageStyle, StyleSheet, Text, View, ViewStyle } from 'react-native'; +import React, { useCallback, useMemo } from 'react'; +import { Image, StyleSheet, Text, View } from 'react-native'; import dayjs from 'dayjs'; +import { LocalMessage, MessageComposerState, PollState } from 'stream-chat'; -import merge from 'lodash/merge'; - -import type { Attachment, MessageComposerState, PollState } from 'stream-chat'; - -import { useChatContext, useMessageComposer } from '../../contexts'; -import { useChatConfigContext } from '../../contexts/chatConfigContext/ChatConfigContext'; -import { useMessageContext } from '../../contexts/messageContext/MessageContext'; +import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { - MessagesContextValue, - useMessagesContext, -} from '../../contexts/messagesContext/MessagesContext'; + MessageContextValue, + useMessageContext, +} from '../../contexts/messageContext/MessageContext'; +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; +import { MessagesContextValue } from '../../contexts/messagesContext/MessagesContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { - TranslationContextValue, - useTranslationContext, -} from '../../contexts/translationContext/TranslationContext'; import { useStateStore } from '../../hooks'; +import { NewFile } from '../../icons/NewFile'; +import { NewLink } from '../../icons/NewLink'; +import { NewMapPin } from '../../icons/NewMapPin'; +import { NewMic } from '../../icons/NewMic'; +import { NewPhoto } from '../../icons/NewPhoto'; +import { NewPlayIcon } from '../../icons/NewPlayIcon'; +import { NewPoll } from '../../icons/NewPoll'; +import { NewVideo } from '../../icons/NewVideo'; import { FileTypes } from '../../types/types'; -import { getResizedImageUrl } from '../../utils/getResizedImageUrl'; -import { getTrimmedAttachmentTitle } from '../../utils/getTrimmedAttachmentTitle'; -import { checkQuotedMessageEquality, hasOnlyEmojis } from '../../utils/utils'; +import { checkQuotedMessageEquality } from '../../utils/utils'; +import { FileIcon } from '../Attachment/FileIcon'; +import { DismissAttachmentUpload } from '../MessageInput/components/AttachmentPreview/DismissAttachmentUpload'; -import { FileIcon as FileIconDefault } from '../Attachment/FileIcon'; -import { VideoThumbnail } from '../Attachment/VideoThumbnail'; -import { MessageAvatar as MessageAvatarDefault } from '../Message/MessageSimple/MessageAvatar'; -import { MessageTextContainer } from '../Message/MessageSimple/MessageTextContainer'; - -const styles = StyleSheet.create({ - container: { - alignItems: 'flex-end', - flexDirection: 'row', - }, - fileAttachmentContainer: { paddingLeft: 8, paddingVertical: 8 }, - imageAttachment: { - borderRadius: 8, - height: 32, - marginLeft: 8, - marginVertical: 8, - width: 32, - }, - messageContainer: { - alignItems: 'flex-start', - borderBottomLeftRadius: 0, - borderBottomRightRadius: 12, - borderTopLeftRadius: 12, - borderTopRightRadius: 12, - flexDirection: 'row', - flexGrow: 1, - flexShrink: 1, - }, - secondaryText: { - paddingHorizontal: 8, - }, - text: { fontSize: 12, fontWeight: 'bold', overflow: 'hidden' }, - textContainer: { maxWidth: undefined, paddingHorizontal: 8 }, - videoThumbnailContainerStyle: { - borderRadius: 8, - height: 50, - marginLeft: 8, - marginVertical: 8, - width: 50, - }, - videoThumbnailImageStyle: { - borderRadius: 10, - }, +const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ + quotedMessage: state.quotedMessage, }); -export type ReplySelectorReturnType = { - name?: string; -}; - -const selector = (nextValue: PollState): ReplySelectorReturnType => ({ +const selector = (nextValue: PollState) => ({ name: nextValue.name, }); -const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ - quotedMessage: state.quotedMessage, +const RightContent = React.memo((props: { message: LocalMessage }) => { + const { message } = props; + const attachments = message?.attachments; + + if (!attachments || attachments.length > 1) { + return null; + } + + const attachment = attachments?.[0]; + + if (attachment?.type === FileTypes.Image) { + return ( + + + + ); + } + if (attachment?.type === FileTypes.Video) { + return ( + + + + + + + + + ); + } + + if (attachment?.type === FileTypes.File) { + return ; + } + + return null; }); -type ReplyPropsWithContext = Pick< - MessagesContextValue, - 'FileAttachmentIcon' | 'MessageAvatar' | 'quotedMessage' -> & - Pick & { - attachmentSize?: number; - styles?: Partial<{ - container: ViewStyle; - fileAttachmentContainer: ViewStyle; - imageAttachment: ImageStyle; - messageContainer: ViewStyle; - textContainer: ViewStyle; - }>; - }; +const SubtitleText = React.memo(({ message }: { message?: LocalMessage | null }) => { + const { client } = useChatContext(); + const poll = client.polls.fromState(message?.poll_id ?? ''); + const { name: pollName } = useStateStore(poll?.state, selector) ?? {}; + const { + theme: { + reply: { subtitle: subtitleStyle }, + }, + } = useTheme(); -const getMessageType = (lastAttachment: Attachment) => { - let messageType; + const subtitle = useMemo(() => { + const attachments = message?.attachments; + const audioAttachments = attachments?.filter( + (attachment) => attachment.type === FileTypes.Audio, + ); + const imageAttachments = attachments?.filter( + (attachment) => attachment.type === FileTypes.Image, + ); + const videoAttachments = attachments?.filter( + (attachment) => attachment.type === FileTypes.Video, + ); + const fileAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.File); + const voiceRecordingAttachments = attachments?.filter( + (attachment) => attachment.type === FileTypes.VoiceRecording, + ); + const onlyImages = imageAttachments?.length && imageAttachments?.length === attachments?.length; + const onlyVideos = videoAttachments?.length && videoAttachments?.length === attachments?.length; + const onlyFiles = fileAttachments?.length && fileAttachments?.length === attachments?.length; + const onlyAudio = audioAttachments?.length === attachments?.length; + const onlyVoiceRecordings = + voiceRecordingAttachments?.length && + voiceRecordingAttachments?.length === attachments?.length; + + if (pollName) { + return pollName; + } - const isLastAttachmentFile = lastAttachment.type === FileTypes.File; + if (message?.shared_location) { + if ( + message?.shared_location?.end_at && + new Date(message?.shared_location?.end_at) > new Date() + ) { + return 'Live Location'; + } + return 'Location'; + } - const isLastAttachmentAudio = lastAttachment.type === FileTypes.Audio; + if (message?.text) { + return message?.text; + } - const isLastAttachmentVoiceRecording = lastAttachment.type === FileTypes.VoiceRecording; + if (imageAttachments?.length && videoAttachments?.length) { + return `${imageAttachments?.length + videoAttachments.length} Media`; + } - const isLastAttachmentVideo = lastAttachment.type === FileTypes.Video; + if (onlyImages) { + if (imageAttachments?.length === 1) { + return 'Photo'; + } else { + return `${imageAttachments?.length} Photos`; + } + } - const isLastAttachmentGiphy = - lastAttachment?.type === FileTypes.Giphy || lastAttachment?.type === FileTypes.Imgur; + if (onlyVideos) { + if (videoAttachments?.length === 1) { + return 'Video'; + } else { + return `${videoAttachments?.length} Videos`; + } + } - const isLastAttachmentImageOrGiphy = - lastAttachment?.type === FileTypes.Image && - !lastAttachment?.title_link && - !lastAttachment?.og_scrape_url; + if (onlyAudio) { + if (audioAttachments?.length === 1) { + return 'Audio'; + } else { + return `${audioAttachments?.length} Audios`; + } + } - const isLastAttachmentImage = lastAttachment?.image_url || lastAttachment?.thumb_url; + if (onlyVoiceRecordings) { + if (voiceRecordingAttachments?.length === 1) { + return `Voice message (${dayjs.duration(voiceRecordingAttachments?.[0]?.duration ?? 0, 'seconds').format('m:ss')})`; + } else { + return `${voiceRecordingAttachments?.length} Voice messages`; + } + } - if (isLastAttachmentFile) { - messageType = FileTypes.File; - } else if (isLastAttachmentVideo) { - messageType = FileTypes.Video; - } else if (isLastAttachmentAudio) { - messageType = FileTypes.Audio; - } else if (isLastAttachmentVoiceRecording) { - messageType = FileTypes.VoiceRecording; - } else if (isLastAttachmentImageOrGiphy) { - if (isLastAttachmentImage) { - messageType = FileTypes.Image; - } else { - messageType = undefined; + if (onlyFiles && fileAttachments?.length === 1) { + return fileAttachments?.[0]?.title; } - } else if (isLastAttachmentGiphy) { - messageType = FileTypes.Giphy; - } else { - messageType = 'other'; + + return `${attachments?.length} Files`; + }, [message?.attachments, message?.shared_location, message?.text, pollName]); + + if (!subtitle) { + return null; } - return messageType; -}; + return ( + + {subtitle} + + ); +}); -const ReplyWithContext = (props: ReplyPropsWithContext) => { - const { client } = useChatContext(); +const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => { + const { message } = props; const { - attachmentSize = 40, - FileAttachmentIcon, - MessageAvatar, - quotedMessage, - styles: stylesProp = {}, - t, - } = props; + theme: { + reply: { pollIcon, locationIcon, linkIcon, audioIcon, fileIcon, videoIcon, photoIcon }, + }, + } = useTheme(); + if (!message) { + return null; + } + + const attachments = message?.attachments; + const audioAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.Audio); + const imageAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.Image); + const videoAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.Video); + const voiceRecordingAttachments = attachments?.filter( + (attachment) => attachment.type === FileTypes.VoiceRecording, + ); + const fileAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.File); + const onlyAudio = audioAttachments?.length && audioAttachments?.length === attachments?.length; + const onlyVideos = videoAttachments?.length && videoAttachments?.length === attachments?.length; + const onlyVoiceRecordings = + voiceRecordingAttachments?.length && voiceRecordingAttachments?.length === attachments?.length; + const hasLink = attachments?.some( + (attachment) => attachment.type === FileTypes.Image && attachment.og_scrape_url, + ); + + if (message.poll_id) { + return ( + + ); + } + + if (message.shared_location) { + return ( + + ); + } + + if (hasLink) { + return ( + + ); + } + + if (onlyAudio || onlyVoiceRecordings) { + return ( + + ); + } + + if (fileAttachments?.length) { + return ( + + ); + } + + if (onlyVideos) { + return ( + + ); + } + + if (imageAttachments?.length) { + return ( + + ); + } - const { resizableCDNHosts } = useChatConfigContext(); + return null; +}); - const [error, setError] = useState(false); +export type ReplyPropsWithContext = Pick & + Pick & { + isMyMessage: boolean; + onDismiss: () => void; + mode: 'reply' | 'edit'; + }; +export const ReplyWithContext = (props: ReplyPropsWithContext) => { + const { isMyMessage, message: messageFromContext, mode, onDismiss, quotedMessage } = props; const { theme: { - colors: { blue_alice, border, grey, transparent, white }, - messageSimple: { - content: { deletedText }, - }, + colors: { grey_whisper }, reply: { + wrapper, container, - fileAttachmentContainer, - imageAttachment, - markdownStyles, - messageContainer, - secondaryText, - textContainer, - videoThumbnail: { - container: videoThumbnailContainerStyle, - image: videoThumbnailImageStyle, - }, + leftContainer, + rightContainer, + title: titleStyle, + subtitleContainer, + dismissWrapper, }, }, } = useTheme(); - const poll = client.polls.fromState(quotedMessage?.poll_id ?? ''); - const { name: pollName }: ReplySelectorReturnType = useStateStore(poll?.state, selector) ?? {}; - - const messageText = quotedMessage ? quotedMessage.text : ''; - - const emojiOnlyText = useMemo(() => { - if (!messageText) { - return false; - } - return hasOnlyEmojis(messageText); - }, [messageText]); + const title = useMemo( + () => + mode === 'edit' + ? 'Edit Message' + : isMyMessage + ? 'You' + : `Reply to ${quotedMessage?.user?.name}`, + [mode, isMyMessage, quotedMessage?.user?.name], + ); if (!quotedMessage) { return null; } - const lastAttachment = quotedMessage.attachments?.slice(-1)[0] as Attachment; - const messageType = lastAttachment && getMessageType(lastAttachment); - - const trimmedLastAttachmentTitle = getTrimmedAttachmentTitle(lastAttachment?.title); - - const hasImage = - !error && - lastAttachment && - messageType !== FileTypes.File && - messageType !== FileTypes.Video && - messageType !== FileTypes.Audio && - messageType !== FileTypes.VoiceRecording && - (lastAttachment.image_url || lastAttachment.thumb_url || lastAttachment.og_scrape_url); - - const onlyEmojis = !lastAttachment && emojiOnlyText; - return ( - - + - {!error && lastAttachment ? ( - messageType === FileTypes.File || - messageType === FileTypes.Audio || - messageType === FileTypes.VoiceRecording ? ( - - - - ) : hasImage ? ( - setError(true)} - source={{ - uri: getResizedImageUrl({ - height: - (stylesProp.imageAttachment?.height as number) || - (imageAttachment?.height as number) || - styles.imageAttachment.height, - resizableCDNHosts, - url: (lastAttachment.image_url || - lastAttachment.thumb_url || - lastAttachment.og_scrape_url) as string, - width: - (stylesProp.imageAttachment?.width as number) || - (imageAttachment?.width as number) || - styles.imageAttachment.width, - }), - }} - style={[styles.imageAttachment, imageAttachment, stylesProp.imageAttachment]} - /> - ) : null - ) : null} - {messageType === FileTypes.Video && !lastAttachment.og_scrape_url ? ( - - ) : null} - - 170 - ? `${quotedMessage.text.slice(0, 170)}...` - : quotedMessage.text - : messageType === FileTypes.Image - ? t('Photo') - : messageType === FileTypes.Video - ? t('Video') - : messageType === FileTypes.File || - messageType === FileTypes.Audio || - messageType === FileTypes.VoiceRecording - ? trimmedLastAttachmentTitle || '' - : '', - }} - onlyEmojis={onlyEmojis} - styles={{ - textContainer: [ - { - marginRight: - hasImage || messageType === FileTypes.Video - ? Number( - stylesProp.imageAttachment?.height || - imageAttachment.height || - styles.imageAttachment.height, - ) + - Number( - stylesProp.imageAttachment?.marginLeft || - imageAttachment.marginLeft || - styles.imageAttachment.marginLeft, - ) - : messageType === FileTypes.File || - messageType === FileTypes.Audio || - messageType === FileTypes.VoiceRecording - ? attachmentSize + - Number( - stylesProp.fileAttachmentContainer?.paddingLeft || - fileAttachmentContainer.paddingLeft || - styles.fileAttachmentContainer.paddingLeft, - ) - : undefined, - }, - styles.textContainer, - textContainer, - stylesProp.textContainer, - ], - }} - /> - {messageType === FileTypes.Audio || messageType === FileTypes.VoiceRecording ? ( - - {lastAttachment.duration - ? dayjs.duration(lastAttachment.duration, 'second').format('mm:ss') - : ''} - - ) : null} + + + {title} + + + + + + + + + {!messageFromContext?.quoted_message ? ( + + + + ) : null} ); }; const areEqual = (prevProps: ReplyPropsWithContext, nextProps: ReplyPropsWithContext) => { - const { quotedMessage: prevQuotedMessage } = prevProps; - const { quotedMessage: nextQuotedMessage } = nextProps; + const { + isMyMessage: prevIsMyMessage, + mode: prevMode, + quotedMessage: prevQuotedMessage, + } = prevProps; + const { + isMyMessage: nextIsMyMessage, + mode: nextMode, + quotedMessage: nextQuotedMessage, + } = nextProps; - const quotedMessageEqual = - !!prevQuotedMessage && - !!nextQuotedMessage && - checkQuotedMessageEquality(prevQuotedMessage, nextQuotedMessage); + const isMyMessageEqual = prevIsMyMessage === nextIsMyMessage; - const quotedMessageAttachmentsEqual = - prevQuotedMessage?.attachments?.length === nextQuotedMessage?.attachments?.length; + if (!isMyMessageEqual) { + return false; + } - if (!quotedMessageAttachmentsEqual) { + const modeEqual = prevMode === nextMode; + if (!modeEqual) { return false; } - if (!quotedMessageEqual) { + const messageEqual = + prevQuotedMessage && + nextQuotedMessage && + checkQuotedMessageEquality(prevQuotedMessage, nextQuotedMessage); + if (!messageEqual) { return false; } return true; }; -const MemoizedReply = React.memo(ReplyWithContext, areEqual) as typeof ReplyWithContext; +export const MemoizedReply = React.memo(ReplyWithContext, areEqual) as typeof ReplyWithContext; export type ReplyProps = Partial; -/** - * UI Component for reply - */ export const Reply = (props: ReplyProps) => { - const { message } = useMessageContext(); - - const { FileAttachmentIcon = FileIconDefault, MessageAvatar = MessageAvatarDefault } = - useMessagesContext(); + const { message: messageFromContext } = useMessageContext(); + const { client } = useChatContext(); const messageComposer = useMessageComposer(); - const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector); + const { quotedMessage: quotedMessageFromComposer } = useStateStore( + messageComposer.state, + messageComposerStateStoreSelector, + ); + + const onDismiss = useCallback(() => { + messageComposer.setQuotedMessage(null); + }, [messageComposer]); + + const quotedMessage = messageFromContext + ? (messageFromContext.quoted_message as MessagesContextValue['quotedMessage']) + : quotedMessageFromComposer; + + const isMyMessage = client.user?.id === quotedMessage?.user?.id; - const { t } = useTranslationContext(); + const mode = messageComposer.editedMessage ? 'edit' : 'reply'; return ( ); }; -Reply.displayName = 'Reply{reply}'; +const styles = StyleSheet.create({ + attachmentContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + container: { + borderRadius: 12, + flexDirection: 'row', + padding: 8, + }, + contentWrapper: { + backgroundColor: 'white', + borderColor: '#E2E6EA', + borderRadius: 8, + borderWidth: 1, + height: 40, + overflow: 'hidden', + width: 40, + }, + dismissWrapper: { + position: 'absolute', + right: 0, + top: 0, + }, + iconStyle: {}, + imageAttachment: {}, + leftContainer: { + borderLeftColor: '#B8BEC4', + borderLeftWidth: 2, + flex: 1, + justifyContent: 'center', + paddingHorizontal: 8, + paddingVertical: 2, + }, + playIconContainer: { + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + borderRadius: 10, + height: 20, + justifyContent: 'center', + width: 20, + }, + rightContainer: {}, + subtitle: { + color: '#384047', + flexShrink: 1, + fontSize: 12, + includeFontPadding: false, + lineHeight: 16, + }, + subtitleContainer: { + alignItems: 'center', + flexDirection: 'row', + gap: 4, + paddingTop: 4, + }, + title: { + color: '#384047', + fontSize: 12, + fontWeight: 'bold', + includeFontPadding: false, + lineHeight: 16, + }, + wrapper: { + padding: 4, + }, +}); diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index b9c34b762b..36267f673e 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -21,7 +21,6 @@ exports[`Thread should match thread snapshot 1`] = ` style={ [ { - "alignItems": "center", "flex": 1, "width": "100%", }, @@ -39,7 +38,7 @@ exports[`Thread should match thread snapshot 1`] = ` contentContainerStyle={ [ { - "paddingBottom": 4, + "paddingTop": 0, }, undefined, {}, @@ -1866,24 +1865,32 @@ exports[`Thread should match thread snapshot 1`] = ` - - - - - - - - + } + strokeLinecap={1} + strokeLinejoin={1} + strokeWidth={1.5} + /> - - - - - - - - - - - - - - + - + - - - + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + [ + { + "alignItems": "center", + "justifyContent": "center", + }, + { + "borderRadius": 16, + "height": 32, + "width": 32, + }, + { + "backgroundColor": "#FFFFFF", + "borderColor": "#E2E6EA", + "borderWidth": 0, + }, + undefined, + ] + } + > + + + + + + + + + @@ -2438,6 +2406,9 @@ exports[`Thread should match thread snapshot 1`] = ` | React.ReactNode; + iconColor?: string; + onPress?: () => void; + size?: 'sm' | 'md' | 'lg'; + status?: 'disabled' | 'pressed' | 'selected' | 'enabled'; + type?: 'primary' | 'secondary' | 'destructive'; + category?: 'ghost' | 'filled' | 'outline'; +}; + +const sizes = { + lg: { borderRadius: 24, height: 48, width: 48 }, + md: { borderRadius: 20, height: 40, width: 40 }, + sm: { + borderRadius: 16, + height: 32, + width: 32, + }, +}; + +const getBackgroundColor = ({ + type, + status, +}: { + type: IconButtonProps['type']; + status: IconButtonProps['status']; +}) => { + if (type === 'primary') { + if (status === 'disabled') { + return '#E2E6EA'; + } else { + return '#005FFF'; + } + } else if (type === 'secondary') { + return '#FFFFFF'; + } + return { + destructive: '#D92F26', + primary: '#005FFF', + secondary: '#FFFFFF', + }[type ?? 'primary']; +}; + +export const IconButton = (props: IconButtonProps) => { + const { + category = 'filled', + status = 'enabled', + Icon, + iconColor, + onPress, + size = 'md', + style, + type = 'primary', + ...rest + } = props; + const { + theme: { + colors: { selected: selectedColor }, + }, + } = useTheme(); + return ( + [ + styles.container, + sizes[size], + { + backgroundColor: + status === 'selected' + ? selectedColor + : pressed + ? '#F5F6F7' + : getBackgroundColor({ status, type }), + borderColor: '#E2E6EA', + borderWidth: category === 'outline' || category === 'filled' ? 1 : 0, + }, + style as StyleProp, + ]} + {...rest} + > + {typeof Icon === 'function' ? ( + + ) : ( + {Icon} + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 05a556a80a..e076b7ea7b 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -34,9 +34,7 @@ import { } from '../../components'; import { dismissKeyboard } from '../../components/KeyboardCompatibleView/KeyboardControllerAvoidingView'; import { parseLinksFromText } from '../../components/Message/MessageSimple/utils/parseLinks'; -import type { AttachButtonProps } from '../../components/MessageInput/AttachButton'; -import { AttachmentUploadPreviewListProps } from '../../components/MessageInput/AttachmentUploadPreviewList'; -import type { CommandsButtonProps } from '../../components/MessageInput/CommandsButton'; +import { AttachmentUploadPreviewListProps } from '../../components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList'; import type { AttachmentUploadProgressIndicatorProps } from '../../components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator'; import { AudioAttachmentUploadPreviewProps } from '../../components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview'; import { FileAttachmentUploadPreviewProps } from '../../components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview'; @@ -48,13 +46,12 @@ import type { AudioRecordingLockIndicatorProps } from '../../components/MessageI import type { AudioRecordingPreviewProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingPreview'; import type { AudioRecordingWaveformProps } from '../../components/MessageInput/components/AudioRecorder/AudioRecordingWaveform'; import type { CommandInputProps } from '../../components/MessageInput/components/CommandInput'; -import type { InputEditingStateHeaderProps } from '../../components/MessageInput/components/InputEditingStateHeader'; -import type { CooldownTimerProps } from '../../components/MessageInput/CooldownTimer'; +import type { AttachButtonProps } from '../../components/MessageInput/components/InputButtons/AttachButton'; +import type { InputButtonsProps } from '../../components/MessageInput/components/InputButtons/index'; +import type { CooldownTimerProps } from '../../components/MessageInput/components/OutputButtons/CooldownTimer'; +import type { SendButtonProps } from '../../components/MessageInput/components/OutputButtons/SendButton'; import { useCooldown } from '../../components/MessageInput/hooks/useCooldown'; -import type { InputButtonsProps } from '../../components/MessageInput/InputButtons'; import type { MessageInputProps } from '../../components/MessageInput/MessageInput'; -import type { MoreOptionsButtonProps } from '../../components/MessageInput/MoreOptionsButton'; -import type { SendButtonProps } from '../../components/MessageInput/SendButton'; import { useStableCallback } from '../../hooks/useStableCallback'; import { createAttachmentsCompositionMiddleware, @@ -249,13 +246,7 @@ export type InputMessageInputContextValue = { ImageAttachmentUploadPreview: React.ComponentType; FileAttachmentUploadPreview: React.ComponentType; VideoAttachmentUploadPreview: React.ComponentType; - /** - * Custom UI component for commands button. - * - * Defaults to and accepts same props as: - * [CommandsButton](https://getstream.io/chat/docs/sdk/reactnative/ui-components/commands-button/) - */ - CommandsButton: React.ComponentType; + /** * Custom UI component to display the remaining cooldown a user will have to wait before * being allowed to send another message. This component is displayed in place of the @@ -280,17 +271,7 @@ export type InputMessageInputContextValue = { /** When false, ImageSelectorIcon will be hidden */ hasImagePicker: boolean; - InputEditingStateHeader: React.ComponentType; CommandInput: React.ComponentType; - InputReplyStateHeader: React.ComponentType; - /** - * Custom UI component for more options button. - * - * Defaults to and accepts same props as: - * [MoreOptionsButton](https://getstream.io/chat/docs/sdk/reactnative/ui-components/more-options-button/) - */ - MoreOptionsButton: React.ComponentType; - /** * Custom UI component for send button. * @@ -358,6 +339,13 @@ export type InputMessageInputContextValue = { */ handleAttachButtonPress?: () => void; + /** + * Whether the message input is floating or not. + * @type boolean + * @default false + */ + messageInputFloating: boolean; + /** * Custom UI component for AutoCompleteInput. * Has access to all of [MessageInputContext](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/contexts/messageInputContext/MessageInputContext.tsx) diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index 7ad8d8f712..9d966bac51 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -31,7 +31,6 @@ export const useCreateMessageInputContext = ({ closeAttachmentPicker, closePollCreationDialog, CommandInput, - CommandsButton, compressImageQuality, cooldownEndsAt, CooldownTimer, @@ -50,9 +49,7 @@ export const useCreateMessageInputContext = ({ Input, inputBoxRef, InputButtons, - InputEditingStateHeader, - InputReplyStateHeader, - MoreOptionsButton, + messageInputFloating, openAttachmentPicker, openPollCreationDialog, pickAndUploadImageFromNativePicker, @@ -105,7 +102,6 @@ export const useCreateMessageInputContext = ({ closeAttachmentPicker, closePollCreationDialog, CommandInput, - CommandsButton, compressImageQuality, cooldownEndsAt, CooldownTimer, @@ -124,9 +120,7 @@ export const useCreateMessageInputContext = ({ Input, inputBoxRef, InputButtons, - InputEditingStateHeader, - InputReplyStateHeader, - MoreOptionsButton, + messageInputFloating, openAttachmentPicker, openPollCreationDialog, pickAndUploadImageFromNativePicker, diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index f1b26969d9..6795d52f00 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -9,7 +9,7 @@ export const BASE_AVATAR_SIZE = 32; export const Colors = { accent_blue: '#005FFF', accent_dark_blue: '#005DFF', - accent_error: '#FF3842', + accent_error: '#D92F26', accent_green: '#20E070', accent_info: '#1FE06F', accent_red: '#FF3742', @@ -31,6 +31,7 @@ export const Colors = { light_gray: '#E9EAED', modal_shadow: '#00000099', // 99 = 60% opacity; x=0, y= 1, radius=4 overlay: '#000000CC', // CC = 80% opacity + selected: 'hsla(0, 0%, 0%, 0.15)', shadow_icon: '#00000040', // 40 = 25% opacity; x=0, y=0, radius=4 static_black: '#000000', static_white: '#ffffff', @@ -272,17 +273,16 @@ export type Theme = { attachButton: ViewStyle; attachButtonContainer: ViewStyle; attachmentSelectionBar: ViewStyle; - attachmentSeparator: ViewStyle; attachmentUnsupportedIndicator: { container: ViewStyle; warningIcon: IconProps; text: TextStyle; }; attachmentUploadPreviewList: { - filesFlatList: ViewStyle; - imagesFlatList: ViewStyle; - wrapper: ViewStyle; + flatList: ViewStyle; + itemSeparator: ViewStyle; }; + audioRecordingButtonContainer: ViewStyle; audioRecorder: { arrowLeftIcon: IconProps; checkContainer: ViewStyle; @@ -295,10 +295,6 @@ export type Theme = { sendCheckIcon: IconProps; slideToCancelContainer: ViewStyle; }; - audioRecordingButton: { - container: ViewStyle; - micIcon: IconProps; - }; audioRecordingInProgress: { container: ViewStyle; durationText: TextStyle; @@ -320,17 +316,15 @@ export type Theme = { container: ViewStyle; waveform: ViewStyle; }; - autoCompleteInputContainer: ViewStyle; commandInput: { closeButton: ViewStyle; container: ViewStyle; text: TextStyle; }; - commandsButton: ViewStyle; - composerContainer: ViewStyle; container: ViewStyle; + contentContainer: ViewStyle; + cooldownButtonContainer: ViewStyle; cooldownTimer: { - container: ViewStyle; text: TextStyle; }; dismissAttachmentUpload: { @@ -338,13 +332,8 @@ export type Theme = { dismissIcon: IconProps; dismissIconColor: ColorValue; }; - editingBoxContainer: ViewStyle; - editingBoxHeader: ViewStyle; - editingBoxHeaderTitle: TextStyle; - editingStateHeader: { - editingBoxHeader: ViewStyle; - editingBoxHeaderTitle: TextStyle; - }; + editButton: ViewStyle; + editButtonContainer: ViewStyle; fileAttachmentUploadPreview: { fileContainer: ViewStyle; filenameText: TextStyle; @@ -356,34 +345,32 @@ export type Theme = { fileUploadPreview: { flatList: ViewStyle; }; + floatingWrapper: ViewStyle; focusedInputBoxContainer: ViewStyle; imageAttachmentUploadPreview: { - itemContainer: ViewStyle; + container: ViewStyle; upload: ImageStyle; + wrapper: ViewStyle; }; - imageUploadPreview: { - flatList: ViewStyle; - }; + inputContainer: ViewStyle; inputBox: TextStyle; inputBoxContainer: ViewStyle; + inputBoxWrapper: ViewStyle; + inputButtonsContainer: ViewStyle; + inputFloatingContainer: ViewStyle; micButtonContainer: ViewStyle; - moreOptionsButton: ViewStyle; nativeAttachmentPicker: { buttonContainer: ViewStyle; buttonDimmerStyle: ViewStyle; container: ViewStyle; }; - optionsContainer: ViewStyle; - replyContainer: ViewStyle; - searchIcon: IconProps; + outputButtonsContainer: ViewStyle; sendButton: ViewStyle; sendButtonContainer: ViewStyle; sendMessageDisallowedIndicator: { container: ViewStyle; text: TextStyle; }; - sendRightIcon: IconProps; - sendUpIcon: IconProps; showThreadMessageInChannelButton: { check: IconProps; checkBoxActive: ViewStyle; @@ -433,6 +420,7 @@ export type Theme = { itemContainer: ViewStyle; upload: ImageStyle; }; + wrapper: ViewStyle; }; messageList: { container: ViewStyle; @@ -452,15 +440,15 @@ export type Theme = { text: TextStyle; textContainer: ViewStyle; }; + scrollToBottomButtonContainer: ViewStyle; scrollToBottomButton: { container: ViewStyle; - touchable: ViewStyle; unreadCountNotificationContainer: ViewStyle; unreadCountNotificationText: TextStyle; - wrapper: ViewStyle; - chevronColor?: ColorValue; }; + stickyHeaderContainer: ViewStyle; typingIndicatorContainer: ViewStyle; + unreadMessagesNotificationContainer: ViewStyle; unreadMessagesNotification: { closeButtonContainer: ViewStyle; closeIcon: IconProps; @@ -842,17 +830,21 @@ export type Theme = { thumb: ViewStyle; }; reply: { + audioIcon: IconProps; container: ViewStyle; - fileAttachmentContainer: ViewStyle; - imageAttachment: ImageStyle; - markdownStyles: MarkdownStyle; - messageContainer: ViewStyle; - secondaryText: ViewStyle; - textContainer: ViewStyle; - videoThumbnail: { - container: ViewStyle; - image: ImageStyle; - }; + dismissWrapper: ViewStyle; + fileIcon: IconProps; + leftContainer: ViewStyle; + locationIcon: IconProps; + linkIcon: IconProps; + photoIcon: IconProps; + pollIcon: IconProps; + rightContainer: ViewStyle; + title: TextStyle; + subtitle: TextStyle; + subtitleContainer: ViewStyle; + videoIcon: IconProps; + wrapper: ViewStyle; }; screenPadding: number; spinner: ViewStyle; @@ -1096,16 +1088,14 @@ export const defaultTheme: Theme = { attachButton: {}, attachButtonContainer: {}, attachmentSelectionBar: {}, - attachmentSeparator: {}, attachmentUnsupportedIndicator: { container: {}, text: {}, warningIcon: {}, }, attachmentUploadPreviewList: { - filesFlatList: {}, - imagesFlatList: {}, - wrapper: {}, + flatList: {}, + itemSeparator: {}, }, audioRecorder: { arrowLeftIcon: {}, @@ -1119,7 +1109,7 @@ export const defaultTheme: Theme = { sendCheckIcon: {}, slideToCancelContainer: {}, }, - audioRecordingButton: { container: {}, micIcon: {} }, + audioRecordingButtonContainer: {}, audioRecordingInProgress: { container: {}, durationText: {} }, audioRecordingLockIndicator: { arrowUpIcon: {}, container: {}, lockIcon: {} }, audioRecordingPreview: { @@ -1131,17 +1121,15 @@ export const defaultTheme: Theme = { progressBar: {}, }, audioRecordingWaveform: { container: {}, waveform: {} }, - autoCompleteInputContainer: {}, commandInput: { closeButton: {}, container: {}, text: {}, }, - commandsButton: {}, - composerContainer: {}, container: {}, + contentContainer: {}, + cooldownButtonContainer: {}, cooldownTimer: { - container: {}, text: {}, }, dismissAttachmentUpload: { @@ -1149,13 +1137,8 @@ export const defaultTheme: Theme = { dismissIcon: {}, dismissIconColor: '', }, - editingBoxContainer: {}, - editingBoxHeader: {}, - editingBoxHeaderTitle: {}, - editingStateHeader: { - editingBoxHeader: {}, - editingBoxHeaderTitle: {}, - }, + editButton: {}, + editButtonContainer: {}, fileAttachmentUploadPreview: { fileContainer: {}, filenameText: {}, @@ -1167,34 +1150,32 @@ export const defaultTheme: Theme = { fileUploadPreview: { flatList: {}, }, + floatingWrapper: {}, focusedInputBoxContainer: {}, imageAttachmentUploadPreview: { - itemContainer: {}, + container: {}, upload: {}, - }, - imageUploadPreview: { - flatList: {}, + wrapper: {}, }, inputBox: {}, inputBoxContainer: {}, + inputBoxWrapper: {}, + inputButtonsContainer: {}, + inputContainer: {}, + inputFloatingContainer: {}, micButtonContainer: {}, - moreOptionsButton: {}, nativeAttachmentPicker: { buttonContainer: {}, buttonDimmerStyle: {}, container: {}, }, - optionsContainer: {}, - replyContainer: {}, - searchIcon: {}, + outputButtonsContainer: {}, sendButton: {}, sendButtonContainer: {}, sendMessageDisallowedIndicator: { container: {}, text: {}, }, - sendRightIcon: {}, - sendUpIcon: {}, showThreadMessageInChannelButton: { check: {}, checkBoxActive: {}, @@ -1244,6 +1225,7 @@ export const defaultTheme: Theme = { recorderIconContainer: {}, upload: {}, }, + wrapper: {}, }, messageList: { container: {}, @@ -1265,11 +1247,11 @@ export const defaultTheme: Theme = { }, scrollToBottomButton: { container: {}, - touchable: {}, unreadCountNotificationContainer: {}, unreadCountNotificationText: {}, - wrapper: {}, }, + scrollToBottomButtonContainer: {}, + stickyHeaderContainer: {}, typingIndicatorContainer: {}, unreadMessagesNotification: { closeButtonContainer: {}, @@ -1277,6 +1259,7 @@ export const defaultTheme: Theme = { container: {}, text: {}, }, + unreadMessagesNotificationContainer: {}, }, messageMenu: { actionList: { @@ -1667,17 +1650,21 @@ export const defaultTheme: Theme = { thumb: {}, }, reply: { + audioIcon: {}, container: {}, - fileAttachmentContainer: {}, - imageAttachment: {}, - markdownStyles: {}, - messageContainer: {}, - secondaryText: {}, - textContainer: {}, - videoThumbnail: { - container: {}, - image: {}, - }, + dismissWrapper: {}, + fileIcon: {}, + leftContainer: {}, + linkIcon: {}, + locationIcon: {}, + photoIcon: {}, + pollIcon: {}, + rightContainer: {}, + subtitle: {}, + subtitleContainer: {}, + title: {}, + videoIcon: {}, + wrapper: {}, }, screenPadding: 8, spinner: {}, diff --git a/package/src/hooks/useKeyboardVisibility.ts b/package/src/hooks/useKeyboardVisibility.ts new file mode 100644 index 0000000000..e013d41241 --- /dev/null +++ b/package/src/hooks/useKeyboardVisibility.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; +import { EventSubscription, Keyboard } from 'react-native'; + +import { KeyboardControllerPackage } from '../components/KeyboardCompatibleView/KeyboardControllerAvoidingView'; + +/** + * A custom hook that provides a boolean value indicating whether the keyboard is visible. + * @returns A boolean value indicating whether the keyboard is visible. + */ +export const useKeyboardVisibility = () => { + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + + useEffect(() => { + const listeners: EventSubscription[] = []; + if (KeyboardControllerPackage?.KeyboardEvents) { + listeners.push( + KeyboardControllerPackage.KeyboardEvents.addListener('keyboardWillShow', () => + setIsKeyboardVisible(true), + ), + ); + listeners.push( + KeyboardControllerPackage.KeyboardEvents.addListener('keyboardWillHide', () => + setIsKeyboardVisible(false), + ), + ); + } else { + listeners.push(Keyboard.addListener('keyboardWillShow', () => setIsKeyboardVisible(true))); + listeners.push(Keyboard.addListener('keyboardWillHide', () => setIsKeyboardVisible(false))); + } + + return () => listeners.forEach((listener) => listener.remove()); + }, []); + + return isKeyboardVisible; +}; diff --git a/package/src/icons/ErrorCircle.tsx b/package/src/icons/ErrorCircle.tsx new file mode 100644 index 0000000000..358abc8f35 --- /dev/null +++ b/package/src/icons/ErrorCircle.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import Svg, { Path } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const ErrorCircle = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewClose.tsx b/package/src/icons/NewClose.tsx new file mode 100644 index 0000000000..d2bea91841 --- /dev/null +++ b/package/src/icons/NewClose.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import Svg, { Path } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewClose = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewDown.tsx b/package/src/icons/NewDown.tsx new file mode 100644 index 0000000000..0ff62e16f3 --- /dev/null +++ b/package/src/icons/NewDown.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewDown = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewFile.tsx b/package/src/icons/NewFile.tsx new file mode 100644 index 0000000000..615fdfa439 --- /dev/null +++ b/package/src/icons/NewFile.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewFile = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewLink.tsx b/package/src/icons/NewLink.tsx new file mode 100644 index 0000000000..6adb196d6e --- /dev/null +++ b/package/src/icons/NewLink.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewLink = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewMapPin.tsx b/package/src/icons/NewMapPin.tsx new file mode 100644 index 0000000000..905d2684ca --- /dev/null +++ b/package/src/icons/NewMapPin.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewMapPin = ({ height, width, ...rest }: IconProps) => ( + + + + +); diff --git a/package/src/icons/NewMic.tsx b/package/src/icons/NewMic.tsx new file mode 100644 index 0000000000..21c1044862 --- /dev/null +++ b/package/src/icons/NewMic.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewMic = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewPhoto.tsx b/package/src/icons/NewPhoto.tsx new file mode 100644 index 0000000000..6fc8a3b62a --- /dev/null +++ b/package/src/icons/NewPhoto.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewPhoto = ({ height, width, ...rest }: IconProps) => ( + + + + +); diff --git a/package/src/icons/NewPlayIcon.tsx b/package/src/icons/NewPlayIcon.tsx new file mode 100644 index 0000000000..9ca5d3eca0 --- /dev/null +++ b/package/src/icons/NewPlayIcon.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewPlayIcon = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewPlus.tsx b/package/src/icons/NewPlus.tsx new file mode 100644 index 0000000000..e096b8ff42 --- /dev/null +++ b/package/src/icons/NewPlus.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import Svg, { Path } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewPlus = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewPoll.tsx b/package/src/icons/NewPoll.tsx new file mode 100644 index 0000000000..b08396ffc6 --- /dev/null +++ b/package/src/icons/NewPoll.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewPoll = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewTick.tsx b/package/src/icons/NewTick.tsx new file mode 100644 index 0000000000..a9b6bdd89d --- /dev/null +++ b/package/src/icons/NewTick.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewTick = ({ height, width, ...rest }: IconProps) => ( + + + +); diff --git a/package/src/icons/NewVideo.tsx b/package/src/icons/NewVideo.tsx new file mode 100644 index 0000000000..7129118011 --- /dev/null +++ b/package/src/icons/NewVideo.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const NewVideo = ({ height, width, ...rest }: IconProps) => ( + + + + +); diff --git a/package/src/icons/Search.tsx b/package/src/icons/Search.tsx index 060e210780..9b8723df59 100644 --- a/package/src/icons/Search.tsx +++ b/package/src/icons/Search.tsx @@ -1,12 +1,14 @@ import React from 'react'; -import { IconProps, RootPath, RootSvg } from './utils/base'; +import Svg, { Path } from 'react-native-svg'; -export const Search = (props: IconProps) => ( - - ( + + - + ); diff --git a/package/src/icons/SendRight.tsx b/package/src/icons/SendRight.tsx index a7be88c2c7..a01676cd4f 100644 --- a/package/src/icons/SendRight.tsx +++ b/package/src/icons/SendRight.tsx @@ -1,21 +1,21 @@ import React from 'react'; -import Svg, { Circle, Path } from 'react-native-svg'; +import Svg, { Path } from 'react-native-svg'; import { IconProps } from './utils/base'; -type Props = IconProps & { - size: number; -}; - -export const SendRight = ({ size, ...rest }: Props) => ( - - +export const SendRight = ({ height, width, ...rest }: IconProps) => ( + ); diff --git a/package/src/state-store/message-input-height-store.ts b/package/src/state-store/message-input-height-store.ts new file mode 100644 index 0000000000..a8bab1106a --- /dev/null +++ b/package/src/state-store/message-input-height-store.ts @@ -0,0 +1,15 @@ +import { StateStore } from 'stream-chat'; + +export type MessageInputHeightState = { + height: number; +}; + +const INITIAL_STATE: MessageInputHeightState = { + height: 0, +}; + +export const messageInputHeightStore = new StateStore(INITIAL_STATE); + +export const setMessageInputHeight = (height: number) => { + messageInputHeightStore.next({ height }); +};