diff --git a/package/src/components/MessageInput/MessageComposerLeadingView.tsx b/package/src/components/MessageInput/MessageComposerLeadingView.tsx new file mode 100644 index 0000000000..e45e927757 --- /dev/null +++ b/package/src/components/MessageInput/MessageComposerLeadingView.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; + +import Animated, { LinearTransition } from 'react-native-reanimated'; + +import { InputButtons } from './components/InputButtons'; +import { idleRecordingStateSelector } from './utils/audioRecorderSelectors'; + +import { useMessageInputContext } from '../../contexts/messageInputContext/MessageInputContext'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks/useStateStore'; + +export const MessageComposerLeadingView = () => { + const { + theme: { + messageInput: { inputButtonsContainer }, + }, + } = useTheme(); + const { audioRecorderManager, messageInputFloating } = useMessageInputContext(); + const { isRecordingStateIdle } = useStateStore( + audioRecorderManager.state, + idleRecordingStateSelector, + ); + + return isRecordingStateIdle ? ( + + + + ) : null; +}; + +const styles = StyleSheet.create({ + inputButtonsContainer: { + alignSelf: 'flex-end', + }, + shadow: { + elevation: 6, + + shadowColor: 'hsla(0, 0%, 0%, 0.24)', + shadowOffset: { height: 4, width: 0 }, + shadowOpacity: 0.24, + shadowRadius: 12, + }, +}); diff --git a/package/src/components/MessageInput/MessageComposerTrailingView.tsx b/package/src/components/MessageInput/MessageComposerTrailingView.tsx new file mode 100644 index 0000000000..0ff4c0341a --- /dev/null +++ b/package/src/components/MessageInput/MessageComposerTrailingView.tsx @@ -0,0 +1,3 @@ +export const MessageComposerTrailingView = () => { + return null; +}; diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 2f7c4706ea..ad5d2cff7e 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -14,19 +14,18 @@ import Animated, { import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { type MessageComposerState, type TextComposerState, type UserResponse } from 'stream-chat'; +import { type UserResponse } from 'stream-chat'; -import { LinkPreviewList } from './components/LinkPreviewList'; -import { OutputButtons } from './components/OutputButtons'; +import { MicPositionProvider } from './contexts/MicPositionContext'; +import { MessageComposerLeadingView } from './MessageComposerLeadingView'; +import { MessageComposerTrailingView } from './MessageComposerTrailingView'; +import { MessageInputHeaderView } from './MessageInputHeaderView'; +import { MessageInputLeadingView } from './MessageInputLeadingView'; +import { MessageInputTrailingView } from './MessageInputTrailingView'; -import { useHasLinkPreviews } from './hooks/useLinkPreviews'; +import { audioRecorderSelector } from './utils/audioRecorderSelectors'; -import { - ChatContextValue, - useAttachmentManagerState, - useChatContext, - useOwnCapabilitiesContext, -} from '../../contexts'; +import { ChatContextValue, useChatContext, useOwnCapabilitiesContext } from '../../contexts'; import { AttachmentPickerContextValue, useAttachmentPickerContext, @@ -62,7 +61,6 @@ import { MessageInputHeightState } from '../../state-store/message-input-height- import { primitives } from '../../theme'; import { AutoCompleteInput } from '../AutoCompleteInput/AutoCompleteInput'; import { CreatePoll } from '../Poll/CreatePollContent'; -import { GiphyBadge } from '../ui/GiphyBadge'; import { SafeAreaViewWrapper } from '../UIComponents/SafeAreaViewWrapper'; const useStyles = () => { @@ -191,7 +189,6 @@ type MessageInputPropsWithContext = Pick< Pick & Pick & { editing: boolean; - hasAttachments: boolean; isKeyboardVisible: boolean; TextInputComponent?: React.ComponentType< TextInputProps & { @@ -202,16 +199,6 @@ type MessageInputPropsWithContext = Pick< recordingStatus?: string; }; -const textComposerStateSelector = (state: TextComposerState) => ({ - command: state.command, - mentionedUsers: state.mentionedUsers, - suggestions: state.suggestions, -}); - -const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ - quotedMessage: state.quotedMessage, -}); - const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ height: state.height, }); @@ -226,7 +213,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { additionalTextInputProps, asyncMessagesLockDistance, asyncMessagesSlideToCancelDistance, - AttachmentUploadPreviewList, AudioRecorder, AudioRecordingInProgress, AudioRecordingLockIndicator, @@ -237,15 +223,12 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { CreatePollContent, disableAttachmentPicker, editing, - hasAttachments, messageInputFloating, messageInputHeightStore, Input, inputBoxRef, - InputButtons, isKeyboardVisible, members, - Reply, threadList, sendMessage, showPollCreationDialog, @@ -259,33 +242,21 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const styles = useStyles(); const messageComposer = useMessageComposer(); - const { clearEditingState } = useMessageComposerAPIContext(); - const onDismissEditMessage = () => { - clearEditingState(); - }; - const { textComposer } = messageComposer; - const { command } = useStateStore(textComposer.state, textComposerStateSelector); - const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector); const { height } = useStateStore(messageInputHeightStore.store, messageInputHeightStoreSelector); - const hasLinkPreviews = useHasLinkPreviews(); - const { theme: { semantics, messageInput: { attachmentSelectionBar, container, - contentContainer, floatingWrapper, focusedInputBoxContainer, inputBoxContainer, inputBoxWrapper, inputContainer, - inputButtonsContainer, inputFloatingContainer, - outputButtonsContainer, suggestionsListContainer: { container: suggestionListContainer }, wrapper, }, @@ -380,200 +351,154 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const BOTTOM_OFFSET = isKeyboardVisible ? 16 : bottom ? bottom : 16; + const micPositionContextValue = useMemo( + () => ({ micPositionX, micPositionY }), + [micPositionX, micPositionY], + ); + return ( - <> - - messageInputHeightStore.setHeight( - messageInputFloating ? newHeight + BOTTOM_OFFSET : newHeight, - ) - } // BOTTOM OFFSET is the position of the input from the bottom of the screen - style={ - messageInputFloating - ? [styles.wrapper, styles.floatingWrapper, { bottom: BOTTOM_OFFSET }, floatingWrapper] - : [ - styles.wrapper, - { - borderTopWidth: 1, - backgroundColor: semantics.composerBg, - borderColor: semantics.borderCoreDefault, - paddingBottom: BOTTOM_OFFSET, - }, - wrapper, - ] - } - > - {Input ? ( - - ) : ( - - {isRecordingStateIdle ? ( + + <> + + messageInputHeightStore.setHeight( + messageInputFloating ? newHeight + BOTTOM_OFFSET : newHeight, + ) + } // BOTTOM OFFSET is the position of the input from the bottom of the screen + style={ + messageInputFloating + ? [styles.wrapper, styles.floatingWrapper, { bottom: BOTTOM_OFFSET }, floatingWrapper] + : [ + styles.wrapper, + { + borderTopWidth: 1, + backgroundColor: semantics.composerBg, + borderColor: semantics.borderCoreDefault, + paddingBottom: BOTTOM_OFFSET, + }, + wrapper, + ] + } + > + {Input ? ( + + ) : ( + + - {InputButtons && } - - ) : null} - - - {recordingStatus === 'stopped' ? ( - - ) : micLocked ? ( - - ) : null} - {isRecordingStateIdle ? ( + + {recordingStatus === 'stopped' ? ( + + ) : micLocked ? ( + + ) : null} + + + - {editing ? ( - - + ) : ( + <> + + + - - ) : null} - {quotedMessage ? ( - - - - ) : null} - - + + )} + + - ) : null} - - - {!isRecordingStateIdle ? ( - - ) : ( - <> - {command ? ( - - - - ) : null} - - - - )} - - {(recordingStatus === 'idle' || recordingStatus === 'recording') && !micLocked ? ( - - - - ) : null} - - - + + + + )} + + + + {!isRecordingStateIdle ? ( + + + ) : ( + )} - - - - {!isRecordingStateIdle ? ( - - - - ) : null} - - - - - {!disableAttachmentPicker && selectedPicker ? ( + - + - ) : null} - - {showPollCreationDialog ? ( - - - - - - - - - - ) : null} - + + + ) : null} + + {showPollCreationDialog ? ( + + + + + + + + + + ) : null} + + ); }; @@ -590,7 +515,6 @@ const areEqual = ( channel: prevChannel, closePollCreationDialog: prevClosePollCreationDialog, editing: prevEditing, - hasAttachments: prevHasAttachments, isKeyboardVisible: prevIsKeyboardVisible, isOnline: prevIsOnline, openPollCreationDialog: prevOpenPollCreationDialog, @@ -613,7 +537,6 @@ const areEqual = ( editing: nextEditing, isKeyboardVisible: nextIsKeyboardVisible, isOnline: nextIsOnline, - hasAttachments: nextHasAttachments, openPollCreationDialog: nextOpenPollCreationDialog, selectedPicker: nextSelectedPicker, showPollCreationDialog: nextShowPollCreationDialog, @@ -676,11 +599,6 @@ const areEqual = ( return false; } - const hasAttachmentsEqual = prevHasAttachments === nextHasAttachments; - if (!hasAttachmentsEqual) { - return false; - } - const isKeyboardVisibleEqual = prevIsKeyboardVisible === nextIsKeyboardVisible; if (!isKeyboardVisibleEqual) { return false; @@ -726,12 +644,6 @@ const MemoizedMessageInput = React.memo( export type MessageInputProps = Partial; -const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ - micLocked: state.micLocked, - isRecordingStateIdle: state.status === 'idle', - recordingStatus: state.status, -}); - /** * UI Component for message input * It's a consumer of @@ -798,7 +710,6 @@ export const MessageInput = (props: MessageInputProps) => { const { clearEditingState } = useMessageComposerAPIContext(); const { Reply } = useMessagesContext(); - const { attachments } = useAttachmentManagerState(); const isKeyboardVisible = useKeyboardVisibility(); const { micLocked, isRecordingStateIdle, recordingStatus } = useStateStore( @@ -854,7 +765,6 @@ export const MessageInput = (props: MessageInputProps) => { disableAttachmentPicker, editing, FileSelectorIcon, - hasAttachments: attachments.length > 0, ImageSelectorIcon, Input, inputBoxRef, diff --git a/package/src/components/MessageInput/MessageInputHeaderView.tsx b/package/src/components/MessageInput/MessageInputHeaderView.tsx new file mode 100644 index 0000000000..44d55bd6ad --- /dev/null +++ b/package/src/components/MessageInput/MessageInputHeaderView.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; + +import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; + +import { LinkPreviewList } from './components/LinkPreviewList'; +import { useHasLinkPreviews } from './hooks/useLinkPreviews'; + +import { idleRecordingStateSelector } from './utils/audioRecorderSelectors'; +import { messageComposerStateStoreSelector } from './utils/messageComposerSelectors'; + +import { useMessageComposerAPIContext } from '../../contexts/messageComposerContext/MessageComposerAPIContext'; +import { useHasAttachments } from '../../contexts/messageInputContext/hooks/useHasAttachments'; +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useMessageInputContext } from '../../contexts/messageInputContext/MessageInputContext'; +import { useMessagesContext } from '../../contexts/messagesContext/MessagesContext'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks/useStateStore'; +import { primitives } from '../../theme'; + +export const MessageInputHeaderView = () => { + const { + theme: { + messageInput: { contentContainer }, + }, + } = useTheme(); + const messageComposer = useMessageComposer(); + const editing = !!messageComposer.editedMessage; + const { clearEditingState } = useMessageComposerAPIContext(); + const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector); + const hasLinkPreviews = useHasLinkPreviews(); + const { audioRecorderManager, AttachmentUploadPreviewList } = useMessageInputContext(); + const { Reply } = useMessagesContext(); + const { isRecordingStateIdle } = useStateStore( + audioRecorderManager.state, + idleRecordingStateSelector, + ); + const hasAttachments = useHasAttachments(); + + return isRecordingStateIdle ? ( + + {editing ? ( + + + + ) : null} + {quotedMessage ? ( + + + + ) : null} + + + + ) : null; +}; + +const styles = StyleSheet.create({ + contentContainer: { + gap: primitives.spacingXxs, + overflow: 'hidden', + paddingHorizontal: primitives.spacingXs, + }, +}); diff --git a/package/src/components/MessageInput/MessageInputLeadingView.tsx b/package/src/components/MessageInput/MessageInputLeadingView.tsx new file mode 100644 index 0000000000..2037de40d2 --- /dev/null +++ b/package/src/components/MessageInput/MessageInputLeadingView.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { textComposerStateSelector } from './utils/messageComposerSelectors'; + +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useStateStore } from '../../hooks/useStateStore'; +import { primitives } from '../../theme'; +import { GiphyBadge } from '../ui/GiphyBadge'; + +export const MessageInputLeadingView = () => { + const messageComposer = useMessageComposer(); + const { textComposer } = messageComposer; + const { command } = useStateStore(textComposer.state, textComposerStateSelector); + + return command ? ( + + + + ) : null; +}; + +const styles = StyleSheet.create({ + giphyContainer: { + padding: primitives.spacingXs, + }, +}); diff --git a/package/src/components/MessageInput/MessageInputTrailingView.tsx b/package/src/components/MessageInput/MessageInputTrailingView.tsx new file mode 100644 index 0000000000..d0a7531f64 --- /dev/null +++ b/package/src/components/MessageInput/MessageInputTrailingView.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { OutputButtons } from './components/OutputButtons'; + +import { audioRecorderSelector } from './utils/audioRecorderSelectors'; + +import { useMessageInputContext } from '../../contexts/messageInputContext/MessageInputContext'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks/useStateStore'; +import { primitives } from '../../theme'; + +export const MessageInputTrailingView = () => { + const { + theme: { + messageInput: { outputButtonsContainer }, + }, + } = useTheme(); + const { audioRecorderManager } = useMessageInputContext(); + const { micLocked, recordingStatus } = useStateStore( + audioRecorderManager.state, + audioRecorderSelector, + ); + return (recordingStatus === 'idle' || recordingStatus === 'recording') && !micLocked ? ( + + + + ) : null; +}; + +const styles = StyleSheet.create({ + outputButtonsContainer: { + alignSelf: 'flex-end', + padding: primitives.spacingXs, + }, +}); 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 4733e48b26..0f384672ee 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap @@ -40,7 +40,7 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -393,7 +393,7 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -746,7 +746,7 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": true, "expanded": undefined, "selected": undefined, } 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 ce5c623954..364869e599 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap @@ -39,7 +39,7 @@ exports[`SendButton should render a SendButton 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } @@ -389,7 +389,7 @@ exports[`SendButton should render a disabled SendButton 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": true, "expanded": undefined, "selected": undefined, } diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx index 82f95e7385..63dd611ac9 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx @@ -5,7 +5,6 @@ import { Gesture, GestureDetector, State } from 'react-native-gesture-handler'; import Animated, { clamp, runOnJS, - SharedValue, useAnimatedStyle, useSharedValue, withSpring, @@ -23,7 +22,9 @@ import { useStateStore } from '../../../../hooks/useStateStore'; import { NewMic } from '../../../../icons/NewMic'; import { NativeHandlers } from '../../../../native'; import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager'; -import { Button } from '../../../ui'; +import { primitives } from '../../../../theme'; +import { ButtonStylesConfig, useButtonStyles } from '../../../ui/Button/hooks/useButtonStyles'; +import { useMicPositionContext } from '../../contexts/MicPositionContext'; export type AudioRecordingButtonPropsWithContext = Pick< MessageInputContextValue, @@ -49,11 +50,14 @@ export type AudioRecordingButtonPropsWithContext = Pick< * Handler to determine what should happen on press of the mic button. */ handlePress?: () => void; - micPositionX: SharedValue; - micPositionY: SharedValue; cancellableDuration: boolean; }; +const buttonStylesConfig: ButtonStylesConfig = { + variant: 'secondary', + type: 'ghost', +}; + /** * Component to display the mic button on the Message Input. */ @@ -69,21 +73,23 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps uploadVoiceRecording, handleLongPress, handlePress, - micPositionX, - micPositionY, cancellableDuration, status, recording, } = props; + const { micPositionX, micPositionY } = useMicPositionContext(); const activeAudioPlayer = useActiveAudioPlayer(); const scale = useSharedValue(1); + const pressed = useSharedValue(false); const { t } = useTranslationContext(); const { theme: { messageInput: { micButtonContainer }, + semantics, }, } = useTheme(); + const buttonStyles = useButtonStyles(buttonStylesConfig); const onPressHandler = useStableCallback(() => { if (handlePress) { @@ -147,9 +153,9 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps const onTouchGestureEnd = useStableCallback(() => { if (status === 'recording') { if (cancellableDuration) { - runOnJS(onEarlyReleaseHandler)(); + onEarlyReleaseHandler(); } else { - runOnJS(uploadVoiceRecording)(asyncMessagesMultiSendEnabled); + uploadVoiceRecording(asyncMessagesMultiSendEnabled); } } }); @@ -160,17 +166,19 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps .minDuration(asyncMessagesMinimumPressDuration) .onBegin(() => { scale.value = withSpring(0.8, { mass: 0.5 }); + pressed.value = true; }) .onStart(() => { runOnJS(onLongPressHandler)(); }) .onFinalize((e) => { scale.value = withSpring(1, { mass: 0.5 }); + pressed.value = false; if (e.state === State.FAILED) { runOnJS(onPressHandler)(); } }), - [asyncMessagesMinimumPressDuration, onLongPressHandler, onPressHandler, scale], + [asyncMessagesMinimumPressDuration, onLongPressHandler, onPressHandler, scale, pressed], ); const panGesture = useMemo( @@ -222,33 +230,20 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps const animatedStyle = useAnimatedStyle(() => { return { transform: [{ scale: scale.value }], + backgroundColor: pressed.value ? semantics.backgroundCorePressed : 'transparent', }; }); return ( -