diff --git a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx index fefa06de17..e45cd56fb9 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx @@ -252,6 +252,7 @@ const useStyles = () => { paddingLeft: 16, paddingVertical: 12, textAlignVertical: 'center', // for android vertical text centering + alignSelf: 'center', }, }); }, [semantics]); diff --git a/package/src/components/AutoCompleteInput/InputView.tsx b/package/src/components/AutoCompleteInput/InputView.tsx new file mode 100644 index 0000000000..febbdcbf36 --- /dev/null +++ b/package/src/components/AutoCompleteInput/InputView.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { AutoCompleteInput } from './AutoCompleteInput'; + +import { CommandChip } from '../MessageInput/CommandChip'; +import { ShowThreadMessageInChannelButton } from '../MessageInput/ShowThreadMessageInChannelButton'; + +export type InputViewProps = React.ComponentProps; + +export const InputView = (props: InputViewProps) => ( + + + + + + + +); + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + inputRow: { + flexDirection: 'row', + }, +}); diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index ce0a13aaa0..53b1719cfb 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -131,6 +131,7 @@ import { ImageOverlaySelectedComponent as DefaultImageOverlaySelectedComponent } import { AutoCompleteSuggestionHeader as AutoCompleteSuggestionHeaderDefault } from '../AutoCompleteInput/AutoCompleteSuggestionHeader'; import { AutoCompleteSuggestionItem as AutoCompleteSuggestionItemDefault } from '../AutoCompleteInput/AutoCompleteSuggestionItem'; import { AutoCompleteSuggestionList as AutoCompleteSuggestionListDefault } from '../AutoCompleteInput/AutoCompleteSuggestionList'; +import { InputView as InputViewDefault } from '../AutoCompleteInput/InputView'; import { EmptyStateIndicator as EmptyStateIndicatorDefault } from '../Indicators/EmptyStateIndicator'; import { LoadingErrorIndicator as LoadingErrorIndicatorDefault, @@ -190,6 +191,7 @@ import { CooldownTimer as CooldownTimerDefault } from '../MessageInput/component import { SendButton as SendButtonDefault } from '../MessageInput/components/OutputButtons/SendButton'; import { MessageComposerLeadingView as MessageComposerLeadingViewDefault } from '../MessageInput/MessageComposerLeadingView'; import { MessageComposerTrailingView as MessageComposerTrailingViewDefault } from '../MessageInput/MessageComposerTrailingView'; +import { MessageInputFooterView as MessageInputFooterViewDefault } from '../MessageInput/MessageInputFooterView'; import { MessageInputHeaderView as MessageInputHeaderViewDefault } from '../MessageInput/MessageInputHeaderView'; import { MessageInputLeadingView as MessageInputLeadingViewDefault } from '../MessageInput/MessageInputLeadingView'; import { MessageInputTrailingView as MessageInputTrailingViewDefault } from '../MessageInput/MessageInputTrailingView'; @@ -666,6 +668,7 @@ const ChannelWithContext = (props: PropsWithChildren) = InlineDateSeparator = InlineDateSeparatorDefault, InlineUnreadIndicator = InlineUnreadIndicatorDefault, Input, + InputView = InputViewDefault, InputButtons = InputButtonsDefault, MessageComposerLeadingView = MessageComposerLeadingViewDefault, MessageComposerTrailingView = MessageComposerTrailingViewDefault, @@ -702,6 +705,7 @@ const ChannelWithContext = (props: PropsWithChildren) = MessageDeleted = MessageDeletedDefault, MessageError = MessageErrorDefault, messageInputFloating = false, + MessageInputFooterView = MessageInputFooterViewDefault, MessageInputHeaderView = MessageInputHeaderViewDefault, MessageInputLeadingView = MessageInputLeadingViewDefault, MessageInputTrailingView = MessageInputTrailingViewDefault, @@ -1884,11 +1888,13 @@ const ChannelWithContext = (props: PropsWithChildren) = hasImagePicker, ImageAttachmentUploadPreview, Input, + InputView, InputButtons, MessageComposerLeadingView, MessageComposerTrailingView, messageInputFloating, messageInputHeightStore, + MessageInputFooterView, MessageInputHeaderView, MessageInputLeadingView, MessageInputTrailingView, diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts index c64f1391f0..bcb57e2529 100644 --- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts +++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts @@ -45,11 +45,13 @@ export const useCreateInputMessageInputContext = ({ hasImagePicker, ImageAttachmentUploadPreview, Input, + InputView, InputButtons, MessageComposerLeadingView, MessageComposerTrailingView, messageInputFloating, messageInputHeightStore, + MessageInputFooterView, MessageInputHeaderView, MessageInputLeadingView, MessageInputTrailingView, @@ -112,11 +114,13 @@ export const useCreateInputMessageInputContext = ({ hasImagePicker, ImageAttachmentUploadPreview, Input, + InputView, InputButtons, MessageComposerLeadingView, MessageComposerTrailingView, messageInputFloating, messageInputHeightStore, + MessageInputFooterView, MessageInputHeaderView, MessageInputLeadingView, MessageInputTrailingView, diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 9dd9a82158..d2d569285e 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -62,6 +62,7 @@ import { import { FileTypes } from '../../types/types'; import { checkMessageEquality, + generateRandomId, hasOnlyEmojis, isBlockedMessage, isBouncedMessage, @@ -71,6 +72,9 @@ import type { Thumbnail } from '../Attachment/utils/buildGallery/types'; import { dismissKeyboard } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView'; import { BottomSheetModal } from '../UIComponents'; +const createMessageOverlayId = (messageId?: string) => + `message-overlay-${messageId ?? 'unknown'}-${generateRandomId()}`; + export type TouchableEmitter = | 'failed-image' | 'fileAttachment' @@ -325,6 +329,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { () => isMessageAIGenerated(message), [message, isMessageAIGenerated], ); + const messageOverlayId = useMemo(() => createMessageOverlayId(message.id), [message.id]); const isMessageTypeDeleted = message.type === 'deleted'; const { client } = chatContext; @@ -339,7 +344,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const layout = await measureInWindow(messageWrapperRef, insets); setRect(layout); setOverlayMessageH(layout); - openOverlay(message.id); + openOverlay({ id: messageOverlayId, messageId: message.id }); } catch (e) { console.error(e); } @@ -685,7 +690,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { }; const frozenMessage = useRef(message); - const { active: overlayActive } = useIsOverlayActive(message.id); + const { active: overlayActive } = useIsOverlayActive(messageOverlayId); const messageHasOnlySingleAttachment = !message.text && !message.quoted_message && message.attachments?.length === 1; @@ -709,6 +714,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { lastGroupMessage: groupStyles?.[0] === 'single' || groupStyles?.[0] === 'bottom', members, message: overlayActive ? frozenMessage.current : message, + messageOverlayId, messageContentOrder, messageHasOnlySingleAttachment, myMessageTheme: messagesContext.myMessageTheme, diff --git a/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx b/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx new file mode 100644 index 0000000000..7ae7133225 --- /dev/null +++ b/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx @@ -0,0 +1,122 @@ +import React, { PropsWithChildren } from 'react'; + +import { act, cleanup, renderHook } from '@testing-library/react-native'; + +import { + MessageContextValue, + MessageProvider, +} from '../../../../contexts/messageContext/MessageContext'; +import { generateMessage } from '../../../../mock-builders/generator/message'; +import { finalizeCloseOverlay, openOverlay, overlayStore } from '../../../../state-store'; + +import { useShouldUseOverlayStyles } from '../useShouldUseOverlayStyles'; + +const createMessageContextValue = (overrides: Partial): MessageContextValue => + ({ + actionsEnabled: false, + alignment: 'left', + channel: {} as MessageContextValue['channel'], + deliveredToCount: 0, + dismissOverlay: jest.fn(), + files: [], + groupStyles: [], + handleAction: jest.fn(), + handleToggleReaction: jest.fn(), + hasReactions: false, + images: [], + isMessageAIGenerated: jest.fn(), + isMyMessage: false, + lastGroupMessage: false, + members: {}, + message: generateMessage({ id: 'shared-message-id' }), + messageContentOrder: [], + messageHasOnlySingleAttachment: false, + messageOverlayId: 'message-overlay-default', + onLongPress: jest.fn(), + onlyEmojis: false, + onOpenThread: jest.fn(), + onPress: jest.fn(), + onPressIn: null, + otherAttachments: [], + reactions: [], + readBy: false, + setQuotedMessage: jest.fn(), + showAvatar: false, + showMessageOverlay: jest.fn(), + showReactionsOverlay: jest.fn(), + showMessageStatus: false, + threadList: false, + videos: [], + ...overrides, + }) as MessageContextValue; + +const createWrapper = (value: MessageContextValue) => { + const Wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + + return Wrapper; +}; + +describe('useShouldUseOverlayStyles', () => { + beforeEach(() => { + act(() => { + finalizeCloseOverlay(); + overlayStore.next({ + closing: false, + closingPortalHostBlacklist: [], + id: undefined, + messageId: undefined, + }); + }); + }); + + afterEach(() => { + cleanup(); + + act(() => { + finalizeCloseOverlay(); + overlayStore.next({ + closing: false, + closingPortalHostBlacklist: [], + id: undefined, + messageId: undefined, + }); + }); + }); + + it('tracks overlay activity by messageOverlayId instead of message.id', () => { + const sharedMessage = generateMessage({ id: 'same-message-id' }); + + const first = renderHook(() => useShouldUseOverlayStyles(), { + wrapper: createWrapper( + createMessageContextValue({ + message: sharedMessage, + messageOverlayId: 'message-overlay-first', + }), + ), + }); + + const second = renderHook(() => useShouldUseOverlayStyles(), { + wrapper: createWrapper( + createMessageContextValue({ + message: sharedMessage, + messageOverlayId: 'message-overlay-second', + }), + ), + }); + + expect(first.result.current).toBe(false); + expect(second.result.current).toBe(false); + + act(() => { + openOverlay('message-overlay-first'); + }); + + expect(first.result.current).toBe(true); + expect(second.result.current).toBe(false); + + first.unmount(); + second.unmount(); + }); +}); diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts index 6fc3953a83..7940da517c 100644 --- a/package/src/components/Message/hooks/useCreateMessageContext.ts +++ b/package/src/components/Message/hooks/useCreateMessageContext.ts @@ -34,6 +34,7 @@ export const useCreateMessageContext = ({ lastGroupMessage, members, message, + messageOverlayId, messageContentOrder, myMessageTheme, onLongPress, @@ -86,6 +87,7 @@ export const useCreateMessageContext = ({ lastGroupMessage, members, message, + messageOverlayId, messageContentOrder, myMessageTheme, onLongPress, @@ -117,6 +119,7 @@ export const useCreateMessageContext = ({ lastGroupMessage, membersValue, myMessageThemeString, + messageOverlayId, reactionsValue, stringifiedMessage, stringifiedQuotedMessage, diff --git a/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts b/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts index 4e2497bbdc..9baa0f2046 100644 --- a/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts +++ b/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts @@ -2,8 +2,8 @@ import { useMessageContext } from '../../../contexts'; import { useIsOverlayActive } from '../../../state-store'; export const useShouldUseOverlayStyles = () => { - const { message } = useMessageContext(); - const { active, closing } = useIsOverlayActive(message?.id); + const { messageOverlayId } = useMessageContext(); + const { active, closing } = useIsOverlayActive(messageOverlayId); return active && !closing; }; diff --git a/package/src/components/MessageInput/CommandChip.tsx b/package/src/components/MessageInput/CommandChip.tsx new file mode 100644 index 0000000000..332e5fff33 --- /dev/null +++ b/package/src/components/MessageInput/CommandChip.tsx @@ -0,0 +1,41 @@ +import React, { useEffect } from 'react'; +import { StyleSheet } from 'react-native'; + +import Animated, { LinearTransition, ZoomIn, ZoomOut } from 'react-native-reanimated'; + +import { textComposerStateSelector } from './utils/messageComposerSelectors'; + +import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useStateStore } from '../../hooks/useStateStore'; +import { primitives } from '../../theme'; +import { GiphyChip } from '../ui/GiphyChip'; + +export const CommandChip = () => { + const messageComposer = useMessageComposer(); + const { textComposer, attachmentManager } = messageComposer; + const { command } = useStateStore(textComposer.state, textComposerStateSelector); + + useEffect(() => { + if (attachmentManager.state.getLatestValue().attachments.length > 0) { + textComposer.clearCommand(); + } + }, [textComposer, attachmentManager]); + + return command ? ( + + + + ) : null; +}; + +const styles = StyleSheet.create({ + giphyContainer: { + padding: primitives.spacingSm, + alignSelf: 'flex-end', + }, +}); diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 2238c8d167..ac678ef934 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -54,10 +54,7 @@ import { useStateStore } from '../../hooks/useStateStore'; import { AudioRecorderManagerState } from '../../state-store/audio-recorder-manager'; import { MessageInputHeightState } from '../../state-store/message-input-height-store'; import { primitives } from '../../theme'; -import { - AutoCompleteInput, - type TextInputOverrideComponent, -} from '../AutoCompleteInput/AutoCompleteInput'; +import { type TextInputOverrideComponent } from '../AutoCompleteInput/AutoCompleteInput'; import { CreatePoll } from '../Poll/CreatePollContent'; import { PortalWhileClosingView } from '../UIComponents/PortalWhileClosingView'; import { SafeAreaViewWrapper } from '../UIComponents/SafeAreaViewWrapper'; @@ -153,7 +150,7 @@ const useStyles = () => { }; type MessageInputPropsWithContext = Pick & - Pick & + Pick & Pick< MessageInputContextValue, | 'audioRecorderManager' @@ -172,6 +169,7 @@ type MessageInputPropsWithContext = Pick & | 'closeAttachmentPicker' | 'compressImageQuality' | 'Input' + | 'InputView' | 'inputBoxRef' | 'InputButtons' | 'MessageComposerLeadingView' @@ -179,10 +177,8 @@ type MessageInputPropsWithContext = Pick & | 'messageInputFloating' | 'messageInputHeightStore' | 'MessageInputHeaderView' - | 'MessageInputLeadingView' | 'MessageInputTrailingView' | 'SendButton' - | 'ShowThreadMessageInChannelButton' | 'StartAudioRecordingButton' | 'uploadNewFile' | 'openPollCreationDialog' @@ -199,6 +195,7 @@ type MessageInputPropsWithContext = Pick & Pick & { editing: boolean; isKeyboardVisible: boolean; + threadList?: boolean; TextInputComponent?: TextInputOverrideComponent; isRecordingStateIdle?: boolean; recordingStatus?: string; @@ -223,21 +220,19 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { CreatePollContent, createPollOptionGap, editing, + InputView, MessageComposerLeadingView, MessageComposerTrailingView, messageInputFloating, messageInputHeightStore, MessageInputHeaderView, - MessageInputLeadingView, MessageInputTrailingView, Input, inputBoxRef, isKeyboardVisible, members, - threadList, sendMessage, showPollCreationDialog, - ShowThreadMessageInChannelButton, TextInputComponent, watchers, micLocked, @@ -451,14 +446,10 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { {!isRecordingStateIdle ? ( ) : ( - <> - - - - + )} @@ -467,7 +458,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { )} - {!isRecordingStateIdle ? ( { const { isOnline } = useChatContext(); const ownCapabilities = useOwnCapabilitiesContext(); - const { channel, members, threadList, watchers } = useChannelContext(); + const { channel, members, watchers } = useChannelContext(); const { audioRecorderManager, @@ -692,6 +675,7 @@ export const MessageInput = (props: MessageInputProps) => { compressImageQuality, CreatePollContent, Input, + InputView, inputBoxRef, InputButtons, MessageComposerLeadingView, @@ -699,14 +683,12 @@ export const MessageInput = (props: MessageInputProps) => { messageInputFloating, messageInputHeightStore, MessageInputHeaderView, - MessageInputLeadingView, MessageInputTrailingView, openPollCreationDialog, SendButton, sendMessage, SendMessageDisallowedIndicator, showPollCreationDialog, - ShowThreadMessageInChannelButton, StartAudioRecordingButton, StopMessageStreamingButton, uploadNewFile, @@ -761,6 +743,7 @@ export const MessageInput = (props: MessageInputProps) => { CreatePollContent, editing, Input, + InputView, inputBoxRef, InputButtons, MessageComposerLeadingView, @@ -771,7 +754,6 @@ export const MessageInput = (props: MessageInputProps) => { messageInputFloating, messageInputHeightStore, MessageInputHeaderView, - MessageInputLeadingView, MessageInputTrailingView, openPollCreationDialog, Reply, @@ -779,11 +761,9 @@ export const MessageInput = (props: MessageInputProps) => { sendMessage, SendMessageDisallowedIndicator, showPollCreationDialog, - ShowThreadMessageInChannelButton, StartAudioRecordingButton, StopMessageStreamingButton, t, - threadList, uploadNewFile, watchers, }} diff --git a/package/src/components/MessageInput/MessageInputFooterView.tsx b/package/src/components/MessageInput/MessageInputFooterView.tsx new file mode 100644 index 0000000000..448be05162 --- /dev/null +++ b/package/src/components/MessageInput/MessageInputFooterView.tsx @@ -0,0 +1,3 @@ +export const MessageInputFooterView = () => { + return null; +}; diff --git a/package/src/components/MessageInput/MessageInputLeadingView.tsx b/package/src/components/MessageInput/MessageInputLeadingView.tsx index a8c353e6b7..a0fd596580 100644 --- a/package/src/components/MessageInput/MessageInputLeadingView.tsx +++ b/package/src/components/MessageInput/MessageInputLeadingView.tsx @@ -1,41 +1 @@ -import React, { useEffect } from 'react'; -import { StyleSheet } from 'react-native'; - -import Animated, { LinearTransition, ZoomIn, ZoomOut } from 'react-native-reanimated'; - -import { textComposerStateSelector } from './utils/messageComposerSelectors'; - -import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; -import { useStateStore } from '../../hooks/useStateStore'; -import { primitives } from '../../theme'; -import { GiphyChip } from '../ui/GiphyChip'; - -export const MessageInputLeadingView = () => { - const messageComposer = useMessageComposer(); - const { textComposer, attachmentManager } = messageComposer; - const { command } = useStateStore(textComposer.state, textComposerStateSelector); - - useEffect(() => { - if (attachmentManager.state.getLatestValue().attachments.length > 0) { - textComposer.clearCommand(); - } - }, [textComposer, attachmentManager]); - - return command ? ( - - - - ) : null; -}; - -const styles = StyleSheet.create({ - giphyContainer: { - padding: primitives.spacingSm, - alignSelf: 'flex-end', - }, -}); +export const MessageInputLeadingView = () => null; diff --git a/package/src/components/MessageInput/MessageInputTrailingView.tsx b/package/src/components/MessageInput/MessageInputTrailingView.tsx index d0a7531f64..03480f6685 100644 --- a/package/src/components/MessageInput/MessageInputTrailingView.tsx +++ b/package/src/components/MessageInput/MessageInputTrailingView.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { StyleSheet } from 'react-native'; + +import Animated, { LinearTransition } from 'react-native-reanimated'; import { OutputButtons } from './components/OutputButtons'; @@ -22,9 +24,12 @@ export const MessageInputTrailingView = () => { audioRecorderSelector, ); return (recordingStatus === 'idle' || recordingStatus === 'recording') && !micLocked ? ( - + - + ) : null; }; diff --git a/package/src/components/MessageInput/ShowThreadMessageInChannelButton.tsx b/package/src/components/MessageInput/ShowThreadMessageInChannelButton.tsx index 51cc4bb55d..a5ed38e7c2 100644 --- a/package/src/components/MessageInput/ShowThreadMessageInChannelButton.tsx +++ b/package/src/components/MessageInput/ShowThreadMessageInChannelButton.tsx @@ -1,9 +1,14 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; +import Animated, { LinearTransition } from 'react-native-reanimated'; + import { MessageComposerState } from 'stream-chat'; -import { ChannelContextValue } from '../../contexts/channelContext/ChannelContext'; +import { + ChannelContextValue, + useChannelContext, +} from '../../contexts/channelContext/ChannelContext'; import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; @@ -13,6 +18,7 @@ import { } from '../../contexts/translationContext/TranslationContext'; import { useStateStore } from '../../hooks/useStateStore'; import { Check } from '../../icons'; +import { primitives } from '../../theme'; const stateSelector = (state: MessageComposerState) => ({ showReplyInChannel: state.showReplyInChannel, @@ -27,6 +33,7 @@ export type ShowThreadMessageInChannelButtonWithContextProps = Pick< export const ShowThreadMessageInChannelButtonWithContext = ( props: ShowThreadMessageInChannelButtonWithContextProps, ) => { + const styles = useStyles(); const { allowThreadMessagesInChannel, t, threadList } = props; const messageComposer = useMessageComposer(); const { showReplyInChannel } = useStateStore(messageComposer.state, stateSelector); @@ -56,7 +63,11 @@ export const ShowThreadMessageInChannelButtonWithContext = ( } return ( - + ({ opacity: pressed ? 0.8 : 1 })}> {t('Also send to channel')} - + ); }; @@ -125,12 +136,14 @@ export type ShowThreadMessageInChannelButtonProps = export const ShowThreadMessageInChannelButton = (props: ShowThreadMessageInChannelButtonProps) => { const { t } = useTranslationContext(); const { allowThreadMessagesInChannel } = useThreadContext(); + const { threadList } = useChannelContext(); return ( @@ -140,25 +153,37 @@ export const ShowThreadMessageInChannelButton = (props: ShowThreadMessageInChann ShowThreadMessageInChannelButton.displayName = 'ShowThreadMessageInChannelButton{messageInput{showThreadMessageInChannelButton}}'; -const styles = StyleSheet.create({ - checkBox: { - alignItems: 'center', - borderRadius: 3, - borderWidth: 2, - height: 16, - justifyContent: 'center', - width: 16, - }, - container: { - flexDirection: 'row', - marginHorizontal: 2, - marginTop: 8, - }, - innerContainer: { - flexDirection: 'row', - }, - text: { - fontSize: 13, - marginLeft: 12, - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + checkBox: { + alignItems: 'center', + borderRadius: primitives.radiusSm, + borderWidth: 1, + height: 20, + justifyContent: 'center', + width: 20, + }, + container: { + flexDirection: 'row', + paddingLeft: primitives.spacingSm, + paddingBottom: primitives.spacingSm, + }, + innerContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + text: { + fontSize: primitives.typographyFontSizeXs, + lineHeight: primitives.typographyLineHeightTight, + color: semantics.textTertiary, + paddingLeft: 12, + }, + }), + [semantics.textTertiary], + ); +}; diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index 4edc298e1f..e12108745f 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { ThreadFooterComponent } from './components/ThreadFooterComponent'; @@ -76,7 +76,7 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { additionalMessageInputProps, additionalMessageListProps, additionalMessageFlashListProps, - autoFocus = true, + autoFocus = false, closeThread, closeThreadOnDismount = true, disabled, @@ -121,6 +121,14 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { [parentMessagePreventPress], ); + const additionalTextInputProps = useMemo( + () => ({ + editable: !disabled, + autoFocus, + }), + [disabled, autoFocus], + ); + if (!thread?.id) { return null; } @@ -141,10 +149,7 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { /> )} 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 fb22e184d8..8559374ad1 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -1986,41 +1986,158 @@ exports[`Thread should match thread snapshot 1`] = ` ] } > - + > + + + + + + + + + Also send to channel + + + + + - - - - - - Also send to channel - - - - { closing: false, closingPortalHostBlacklist: [], id: undefined, + messageId: undefined, }); }); }); @@ -72,6 +73,7 @@ describe('PortalWhileClosingView', () => { closing: false, closingPortalHostBlacklist: [], id: undefined, + messageId: undefined, }); }); diff --git a/package/src/components/index.ts b/package/src/components/index.ts index c365b49536..dfd55785ba 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -21,6 +21,7 @@ export * from './AutoCompleteInput/AutoCompleteInput'; export * from './AutoCompleteInput/AutoCompleteSuggestionHeader'; export * from './AutoCompleteInput/AutoCompleteSuggestionItem'; export * from './AutoCompleteInput/AutoCompleteSuggestionList'; +export * from './AutoCompleteInput/InputView'; export * from './Avatar/GroupAvatar'; @@ -120,6 +121,7 @@ export * from './MessageInput/components/InputButtons'; export * from './MessageInput/MessageInput'; export * from './MessageInput/MessageComposerLeadingView'; export * from './MessageInput/MessageComposerTrailingView'; +export * from './MessageInput/MessageInputFooterView'; export * from './MessageInput/MessageInputHeaderView'; export * from './MessageInput/MessageInputLeadingView'; export * from './MessageInput/MessageInputTrailingView'; diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index 043c70b793..edb3fc9d63 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -56,6 +56,11 @@ export type MessageContextValue = { lastGroupMessage: boolean; /** Current [message object](https://getstream.io/chat/docs/#message_format) */ message: LocalMessage; + /** + * Stable UI-instance identifier for the rendered message. + * Used for overlay state so two rendered instances of the same message do not collide. + */ + messageOverlayId: string; /** Order to render the message content */ messageContentOrder: MessageContentType[]; /** diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 69b68713bb..89adb4c470 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -33,6 +33,7 @@ import { PollContentProps, StopMessageStreamingButtonProps, } from '../../components'; +import type { InputViewProps } from '../../components/AutoCompleteInput/InputView'; import { dismissKeyboard } from '../../components/KeyboardCompatibleView/KeyboardControllerAvoidingView'; import { parseLinksFromText } from '../../components/Message/MessageSimple/utils/parseLinks'; import { AttachmentUploadPreviewListProps } from '../../components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList'; @@ -260,6 +261,10 @@ export type InputMessageInputContextValue = { * Custom UI component to override message input header content. */ MessageInputHeaderView: React.ComponentType; + /** + * Custom UI component to override message input footer content. + */ + MessageInputFooterView: React.ComponentType; /** * Custom UI component to override leading side of input row. */ @@ -333,6 +338,12 @@ export type InputMessageInputContextValue = { getUsers: () => UserResponse[]; } >; + /** + * Custom UI component to override the combined input body view. + * Defaults to + * [InputView](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AutoCompleteInput/InputView.tsx) + */ + InputView: React.ComponentType; /** * Custom UI component to override buttons on left side of input box * Defaults to diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index d91bf9901f..d31b620c80 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -45,12 +45,14 @@ export const useCreateMessageInputContext = ({ hasImagePicker, ImageAttachmentUploadPreview, Input, + InputView, inputBoxRef, InputButtons, MessageComposerLeadingView, MessageComposerTrailingView, messageInputFloating, messageInputHeightStore, + MessageInputFooterView, MessageInputHeaderView, MessageInputLeadingView, MessageInputTrailingView, @@ -122,12 +124,14 @@ export const useCreateMessageInputContext = ({ hasImagePicker, ImageAttachmentUploadPreview, Input, + InputView, inputBoxRef, InputButtons, MessageComposerLeadingView, MessageComposerTrailingView, messageInputFloating, messageInputHeightStore, + MessageInputFooterView, MessageInputHeaderView, MessageInputLeadingView, MessageInputTrailingView, diff --git a/package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx b/package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx index d422a7f64c..1539f68475 100644 --- a/package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx +++ b/package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx @@ -111,6 +111,7 @@ describe('MessageOverlayHostLayer', () => { closing: false, closingPortalHostBlacklist: [], id: undefined, + messageId: undefined, }); }); }); @@ -124,6 +125,7 @@ describe('MessageOverlayHostLayer', () => { closing: false, closingPortalHostBlacklist: [], id: undefined, + messageId: undefined, }); }); diff --git a/package/src/state-store/__tests__/message-overlay-store.test.tsx b/package/src/state-store/__tests__/message-overlay-store.test.tsx index 575591675f..b18aef459a 100644 --- a/package/src/state-store/__tests__/message-overlay-store.test.tsx +++ b/package/src/state-store/__tests__/message-overlay-store.test.tsx @@ -65,6 +65,7 @@ describe('message overlay store portal hooks', () => { closing: false, closingPortalHostBlacklist: [], id: undefined, + messageId: undefined, }); }); }); @@ -81,6 +82,7 @@ describe('message overlay store portal hooks', () => { closing: false, closingPortalHostBlacklist: [], id: undefined, + messageId: undefined, }); }); @@ -147,6 +149,7 @@ describe('message overlay store portal hooks', () => { closing: true, closingPortalHostBlacklist: [], id: undefined, + messageId: undefined, }); }); diff --git a/package/src/state-store/message-overlay-store.ts b/package/src/state-store/message-overlay-store.ts index 3c32a32fce..802f5acc94 100644 --- a/package/src/state-store/message-overlay-store.ts +++ b/package/src/state-store/message-overlay-store.ts @@ -10,9 +10,15 @@ import { useStateStore } from '../hooks'; type OverlayState = { closingPortalHostBlacklist: string[]; id: string | undefined; + messageId: string | undefined; closing: boolean; }; +type OpenOverlayParams = { + id: string; + messageId?: string; +}; + export type Rect = { x: number; y: number; w: number; h: number } | undefined; export type ClosingPortalLayoutEntry = { id: string; @@ -26,6 +32,7 @@ const DefaultState = { closingPortalHostBlacklist: [], closing: false, id: undefined, + messageId: undefined, }; const DefaultClosingPortalLayoutsState: ClosingPortalLayoutsState = { layouts: {}, @@ -71,12 +78,14 @@ export const bumpOverlayLayoutRevision = (closeCorrectionDeltaY = 0) => { sharedValueController?.incrementCloseCorrectionY(closeCorrectionDeltaY); }; -export const openOverlay = (id: string) => { +export const openOverlay = (params: OpenOverlayParams | string) => { + const overlayPayload = typeof params === 'string' ? { id: params, messageId: undefined } : params; + sharedValueController?.resetCloseCorrectionY(); overlayStore.partialNext({ closing: false, closingPortalHostBlacklist: getCurrentClosingPortalHostBlacklist(), - id, + ...overlayPayload, }); }; @@ -216,6 +225,7 @@ overlayStore.subscribeWithSelector(actionQueueSelector, async ({ active }) => { const selector = (nextState: OverlayState) => ({ closing: nextState.closing, id: nextState.id, + messageId: nextState.messageId, }); export const useOverlayController = () => { @@ -323,11 +333,11 @@ export const useClosingPortalLayouts = () => { const noOpObject = { active: false, closing: false }; -export const useIsOverlayActive = (messageId: string) => { +export const useIsOverlayActive = (id: string) => { const messageOverlaySelector = useCallback( (nextState: OverlayState) => - nextState.id === messageId ? { active: true, closing: nextState.closing } : noOpObject, - [messageId], + nextState.id === id ? { active: true, closing: nextState.closing } : noOpObject, + [id], ); return useStateStore(overlayStore, messageOverlaySelector);