From a0c130a29622b7214270fa770386939b00d4d893 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 4 Feb 2026 15:49:50 +0100 Subject: [PATCH 1/3] feat: add message composer customization slots --- .../components/MessageInput/MessageInput.tsx | 258 ++++++++++++------ .../AudioRecorder/AudioRecordingButton.tsx | 41 +-- package/src/components/ui/Button/Button.tsx | 1 + .../ui/Button/hooks/useButtonStyles.ts | 4 +- .../hooks/useHasAttachments.ts | 15 + 5 files changed, 213 insertions(+), 106 deletions(-) create mode 100644 package/src/contexts/messageInputContext/hooks/useHasAttachments.ts diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 2f7c4706e..65912200b 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -16,6 +16,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { type MessageComposerState, type TextComposerState, type UserResponse } from 'stream-chat'; +import { InputButtons } from './components/InputButtons'; import { LinkPreviewList } from './components/LinkPreviewList'; import { OutputButtons } from './components/OutputButtons'; @@ -39,6 +40,7 @@ import { MessageComposerAPIContextValue, useMessageComposerAPIContext, } from '../../contexts/messageComposerContext/MessageComposerAPIContext'; +import { useHasAttachments } from '../../contexts/messageInputContext/hooks/useHasAttachments'; import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; import { MessageInputContextValue, @@ -191,7 +193,6 @@ type MessageInputPropsWithContext = Pick< Pick & Pick & { editing: boolean; - hasAttachments: boolean; isKeyboardVisible: boolean; TextInputComponent?: React.ComponentType< TextInputProps & { @@ -204,8 +205,6 @@ type MessageInputPropsWithContext = Pick< const textComposerStateSelector = (state: TextComposerState) => ({ command: state.command, - mentionedUsers: state.mentionedUsers, - suggestions: state.suggestions, }); const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ @@ -226,7 +225,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { additionalTextInputProps, asyncMessagesLockDistance, asyncMessagesSlideToCancelDistance, - AttachmentUploadPreviewList, AudioRecorder, AudioRecordingInProgress, AudioRecordingLockIndicator, @@ -237,15 +235,12 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { CreatePollContent, disableAttachmentPicker, editing, - hasAttachments, messageInputFloating, messageInputHeightStore, Input, inputBoxRef, - InputButtons, isKeyboardVisible, members, - Reply, threadList, sendMessage, showPollCreationDialog, @@ -259,33 +254,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, }, @@ -412,18 +395,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { ) : ( - {isRecordingStateIdle ? ( - - {InputButtons && } - - ) : null} + { ) : micLocked ? ( ) : null} - {isRecordingStateIdle ? ( - - {editing ? ( - - - - ) : null} - {quotedMessage ? ( - - - - ) : null} - - - - ) : null} + + { ) : ( <> - {command ? ( - - - - ) : null} + { )} - {(recordingStatus === 'idle' || recordingStatus === 'recording') && !micLocked ? ( - - - - ) : null} + @@ -527,7 +458,9 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { style={lockIndicatorAnimatedStyle} /> - ) : null} + ) : ( + + )} { ); }; +/** + * PRAGMA: MessageComposerLeadingView + * @param state + */ + +const idleRecordingStateSelector = (state: AudioRecorderManagerState) => ({ + isRecordingStateIdle: state.status === 'idle', +}); + +export const MessageComposerLeadingView = () => { + const { + theme: { + messageInput: { inputButtonsContainer }, + }, + } = useTheme(); + const styles = useStyles(); + + const { audioRecorderManager, messageInputFloating } = useMessageInputContext(); + const { isRecordingStateIdle } = useStateStore( + audioRecorderManager.state, + idleRecordingStateSelector, + ); + + return isRecordingStateIdle ? ( + + + + ) : null; +}; + +/** + * PRAGMA: MessageComposerTrailingView + * @param state + */ + +export const MessageComposerTrailingView = () => { + return null; +}; + +/** + * PRAGMA: MessageInputHeaderView + * @param prevProps + */ + +export const MessageInputHeaderView = () => { + const { + theme: { + messageInput: { contentContainer }, + }, + } = useTheme(); + const styles = useStyles(); + + const messageComposer = useMessageComposer(); + const editing = !!messageComposer.editedMessage; + const { clearEditingState } = useMessageComposerAPIContext(); + const onDismissEditMessage = () => { + clearEditingState(); + }; + 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; +}; + +/** + * PRAGMA: MessageInputLeadingView + * @param prevProps + */ + +export const MessageInputLeadingView = () => { + const styles = useStyles(); + const messageComposer = useMessageComposer(); + const { textComposer } = messageComposer; + const { command } = useStateStore(textComposer.state, textComposerStateSelector); + + return command ? ( + + + + ) : null; +}; + +/** + * MessageInputTrailingView + * @param prevProps + */ + +export const MessageInputTrailingView = ({ + micPositionX, + micPositionY, +}: { + micPositionX: Animated.SharedValue; + micPositionY: Animated.SharedValue; +}) => { + const styles = useStyles(); + const { + theme: { + messageInput: { outputButtonsContainer }, + }, + } = useTheme(); + const { audioRecorderManager } = useMessageInputContext(); + const { micLocked, recordingStatus } = useStateStore( + audioRecorderManager.state, + audioRecorderSelector, + ); + return (recordingStatus === 'idle' || recordingStatus === 'recording') && !micLocked ? ( + + + + ) : null; +}; + const areEqual = ( prevProps: MessageInputPropsWithContext, nextProps: MessageInputPropsWithContext, @@ -613,7 +703,6 @@ const areEqual = ( editing: nextEditing, isKeyboardVisible: nextIsKeyboardVisible, isOnline: nextIsOnline, - hasAttachments: nextHasAttachments, openPollCreationDialog: nextOpenPollCreationDialog, selectedPicker: nextSelectedPicker, showPollCreationDialog: nextShowPollCreationDialog, @@ -676,11 +765,6 @@ const areEqual = ( return false; } - const hasAttachmentsEqual = prevHasAttachments === nextHasAttachments; - if (!hasAttachmentsEqual) { - return false; - } - const isKeyboardVisibleEqual = prevIsKeyboardVisible === nextIsKeyboardVisible; if (!isKeyboardVisibleEqual) { return false; @@ -798,7 +882,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 +937,6 @@ export const MessageInput = (props: MessageInputProps) => { disableAttachmentPicker, editing, FileSelectorIcon, - hasAttachments: attachments.length > 0, ImageSelectorIcon, Input, inputBoxRef, diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx index 82f95e738..2c7b7311e 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx @@ -23,7 +23,8 @@ 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'; export type AudioRecordingButtonPropsWithContext = Pick< MessageInputContextValue, @@ -54,6 +55,11 @@ export type AudioRecordingButtonPropsWithContext = Pick< cancellableDuration: boolean; }; +const buttonStylesConfig: ButtonStylesConfig = { + variant: 'secondary', + type: 'ghost', +}; + /** * Component to display the mic button on the Message Input. */ @@ -77,13 +83,16 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps } = props; 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 +156,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 +169,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,24 +233,14 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps const animatedStyle = useAnimatedStyle(() => { return { transform: [{ scale: scale.value }], + backgroundColor: pressed.value ? semantics.backgroundCorePressed : 'transparent', }; }); return ( -