Skip to content

Commit 9496b8b

Browse files
authored
feat: input redesign structure (#3489)
## 🎯 Goal This PR implements the new design for the `Also send to channel` button within the `Thread` version of `MessageInput`. Additionally, it solves an issue with duplicate `overlay` IDs (i.e a message sent in both a thread and a channel, or a duplicate channel in the navigation stack perhaps) which caused the context menu to work improperly. It also aligns various other design decisions across SDKs. ## 🛠 Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent 53a9393 commit 9496b8b

24 files changed

+494
-244
lines changed

package/src/components/AutoCompleteInput/AutoCompleteInput.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ const useStyles = () => {
252252
paddingLeft: 16,
253253
paddingVertical: 12,
254254
textAlignVertical: 'center', // for android vertical text centering
255+
alignSelf: 'center',
255256
},
256257
});
257258
}, [semantics]);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import React from 'react';
2+
import { StyleSheet, View } from 'react-native';
3+
4+
import { AutoCompleteInput } from './AutoCompleteInput';
5+
6+
import { CommandChip } from '../MessageInput/CommandChip';
7+
import { ShowThreadMessageInChannelButton } from '../MessageInput/ShowThreadMessageInChannelButton';
8+
9+
export type InputViewProps = React.ComponentProps<typeof AutoCompleteInput>;
10+
11+
export const InputView = (props: InputViewProps) => (
12+
<View style={styles.container}>
13+
<View style={styles.inputRow}>
14+
<CommandChip />
15+
<AutoCompleteInput {...props} />
16+
</View>
17+
<ShowThreadMessageInChannelButton />
18+
</View>
19+
);
20+
21+
const styles = StyleSheet.create({
22+
container: {
23+
flex: 1,
24+
},
25+
inputRow: {
26+
flexDirection: 'row',
27+
},
28+
});

package/src/components/Channel/Channel.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ import { ImageOverlaySelectedComponent as DefaultImageOverlaySelectedComponent }
131131
import { AutoCompleteSuggestionHeader as AutoCompleteSuggestionHeaderDefault } from '../AutoCompleteInput/AutoCompleteSuggestionHeader';
132132
import { AutoCompleteSuggestionItem as AutoCompleteSuggestionItemDefault } from '../AutoCompleteInput/AutoCompleteSuggestionItem';
133133
import { AutoCompleteSuggestionList as AutoCompleteSuggestionListDefault } from '../AutoCompleteInput/AutoCompleteSuggestionList';
134+
import { InputView as InputViewDefault } from '../AutoCompleteInput/InputView';
134135
import { EmptyStateIndicator as EmptyStateIndicatorDefault } from '../Indicators/EmptyStateIndicator';
135136
import {
136137
LoadingErrorIndicator as LoadingErrorIndicatorDefault,
@@ -190,6 +191,7 @@ import { CooldownTimer as CooldownTimerDefault } from '../MessageInput/component
190191
import { SendButton as SendButtonDefault } from '../MessageInput/components/OutputButtons/SendButton';
191192
import { MessageComposerLeadingView as MessageComposerLeadingViewDefault } from '../MessageInput/MessageComposerLeadingView';
192193
import { MessageComposerTrailingView as MessageComposerTrailingViewDefault } from '../MessageInput/MessageComposerTrailingView';
194+
import { MessageInputFooterView as MessageInputFooterViewDefault } from '../MessageInput/MessageInputFooterView';
193195
import { MessageInputHeaderView as MessageInputHeaderViewDefault } from '../MessageInput/MessageInputHeaderView';
194196
import { MessageInputLeadingView as MessageInputLeadingViewDefault } from '../MessageInput/MessageInputLeadingView';
195197
import { MessageInputTrailingView as MessageInputTrailingViewDefault } from '../MessageInput/MessageInputTrailingView';
@@ -666,6 +668,7 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
666668
InlineDateSeparator = InlineDateSeparatorDefault,
667669
InlineUnreadIndicator = InlineUnreadIndicatorDefault,
668670
Input,
671+
InputView = InputViewDefault,
669672
InputButtons = InputButtonsDefault,
670673
MessageComposerLeadingView = MessageComposerLeadingViewDefault,
671674
MessageComposerTrailingView = MessageComposerTrailingViewDefault,
@@ -702,6 +705,7 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
702705
MessageDeleted = MessageDeletedDefault,
703706
MessageError = MessageErrorDefault,
704707
messageInputFloating = false,
708+
MessageInputFooterView = MessageInputFooterViewDefault,
705709
MessageInputHeaderView = MessageInputHeaderViewDefault,
706710
MessageInputLeadingView = MessageInputLeadingViewDefault,
707711
MessageInputTrailingView = MessageInputTrailingViewDefault,
@@ -1884,11 +1888,13 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
18841888
hasImagePicker,
18851889
ImageAttachmentUploadPreview,
18861890
Input,
1891+
InputView,
18871892
InputButtons,
18881893
MessageComposerLeadingView,
18891894
MessageComposerTrailingView,
18901895
messageInputFloating,
18911896
messageInputHeightStore,
1897+
MessageInputFooterView,
18921898
MessageInputHeaderView,
18931899
MessageInputLeadingView,
18941900
MessageInputTrailingView,

package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,13 @@ export const useCreateInputMessageInputContext = ({
4545
hasImagePicker,
4646
ImageAttachmentUploadPreview,
4747
Input,
48+
InputView,
4849
InputButtons,
4950
MessageComposerLeadingView,
5051
MessageComposerTrailingView,
5152
messageInputFloating,
5253
messageInputHeightStore,
54+
MessageInputFooterView,
5355
MessageInputHeaderView,
5456
MessageInputLeadingView,
5557
MessageInputTrailingView,
@@ -112,11 +114,13 @@ export const useCreateInputMessageInputContext = ({
112114
hasImagePicker,
113115
ImageAttachmentUploadPreview,
114116
Input,
117+
InputView,
115118
InputButtons,
116119
MessageComposerLeadingView,
117120
MessageComposerTrailingView,
118121
messageInputFloating,
119122
messageInputHeightStore,
123+
MessageInputFooterView,
120124
MessageInputHeaderView,
121125
MessageInputLeadingView,
122126
MessageInputTrailingView,

package/src/components/Message/Message.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
import { FileTypes } from '../../types/types';
6363
import {
6464
checkMessageEquality,
65+
generateRandomId,
6566
hasOnlyEmojis,
6667
isBlockedMessage,
6768
isBouncedMessage,
@@ -71,6 +72,9 @@ import type { Thumbnail } from '../Attachment/utils/buildGallery/types';
7172
import { dismissKeyboard } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView';
7273
import { BottomSheetModal } from '../UIComponents';
7374

75+
const createMessageOverlayId = (messageId?: string) =>
76+
`message-overlay-${messageId ?? 'unknown'}-${generateRandomId()}`;
77+
7478
export type TouchableEmitter =
7579
| 'failed-image'
7680
| 'fileAttachment'
@@ -325,6 +329,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
325329
() => isMessageAIGenerated(message),
326330
[message, isMessageAIGenerated],
327331
);
332+
const messageOverlayId = useMemo(() => createMessageOverlayId(message.id), [message.id]);
328333
const isMessageTypeDeleted = message.type === 'deleted';
329334
const { client } = chatContext;
330335

@@ -339,7 +344,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
339344
const layout = await measureInWindow(messageWrapperRef, insets);
340345
setRect(layout);
341346
setOverlayMessageH(layout);
342-
openOverlay(message.id);
347+
openOverlay({ id: messageOverlayId, messageId: message.id });
343348
} catch (e) {
344349
console.error(e);
345350
}
@@ -685,7 +690,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
685690
};
686691

687692
const frozenMessage = useRef(message);
688-
const { active: overlayActive } = useIsOverlayActive(message.id);
693+
const { active: overlayActive } = useIsOverlayActive(messageOverlayId);
689694

690695
const messageHasOnlySingleAttachment =
691696
!message.text && !message.quoted_message && message.attachments?.length === 1;
@@ -709,6 +714,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
709714
lastGroupMessage: groupStyles?.[0] === 'single' || groupStyles?.[0] === 'bottom',
710715
members,
711716
message: overlayActive ? frozenMessage.current : message,
717+
messageOverlayId,
712718
messageContentOrder,
713719
messageHasOnlySingleAttachment,
714720
myMessageTheme: messagesContext.myMessageTheme,
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import React, { PropsWithChildren } from 'react';
2+
3+
import { act, cleanup, renderHook } from '@testing-library/react-native';
4+
5+
import {
6+
MessageContextValue,
7+
MessageProvider,
8+
} from '../../../../contexts/messageContext/MessageContext';
9+
import { generateMessage } from '../../../../mock-builders/generator/message';
10+
import { finalizeCloseOverlay, openOverlay, overlayStore } from '../../../../state-store';
11+
12+
import { useShouldUseOverlayStyles } from '../useShouldUseOverlayStyles';
13+
14+
const createMessageContextValue = (overrides: Partial<MessageContextValue>): MessageContextValue =>
15+
({
16+
actionsEnabled: false,
17+
alignment: 'left',
18+
channel: {} as MessageContextValue['channel'],
19+
deliveredToCount: 0,
20+
dismissOverlay: jest.fn(),
21+
files: [],
22+
groupStyles: [],
23+
handleAction: jest.fn(),
24+
handleToggleReaction: jest.fn(),
25+
hasReactions: false,
26+
images: [],
27+
isMessageAIGenerated: jest.fn(),
28+
isMyMessage: false,
29+
lastGroupMessage: false,
30+
members: {},
31+
message: generateMessage({ id: 'shared-message-id' }),
32+
messageContentOrder: [],
33+
messageHasOnlySingleAttachment: false,
34+
messageOverlayId: 'message-overlay-default',
35+
onLongPress: jest.fn(),
36+
onlyEmojis: false,
37+
onOpenThread: jest.fn(),
38+
onPress: jest.fn(),
39+
onPressIn: null,
40+
otherAttachments: [],
41+
reactions: [],
42+
readBy: false,
43+
setQuotedMessage: jest.fn(),
44+
showAvatar: false,
45+
showMessageOverlay: jest.fn(),
46+
showReactionsOverlay: jest.fn(),
47+
showMessageStatus: false,
48+
threadList: false,
49+
videos: [],
50+
...overrides,
51+
}) as MessageContextValue;
52+
53+
const createWrapper = (value: MessageContextValue) => {
54+
const Wrapper = ({ children }: PropsWithChildren) => (
55+
<MessageProvider value={value}>{children}</MessageProvider>
56+
);
57+
58+
return Wrapper;
59+
};
60+
61+
describe('useShouldUseOverlayStyles', () => {
62+
beforeEach(() => {
63+
act(() => {
64+
finalizeCloseOverlay();
65+
overlayStore.next({
66+
closing: false,
67+
closingPortalHostBlacklist: [],
68+
id: undefined,
69+
messageId: undefined,
70+
});
71+
});
72+
});
73+
74+
afterEach(() => {
75+
cleanup();
76+
77+
act(() => {
78+
finalizeCloseOverlay();
79+
overlayStore.next({
80+
closing: false,
81+
closingPortalHostBlacklist: [],
82+
id: undefined,
83+
messageId: undefined,
84+
});
85+
});
86+
});
87+
88+
it('tracks overlay activity by messageOverlayId instead of message.id', () => {
89+
const sharedMessage = generateMessage({ id: 'same-message-id' });
90+
91+
const first = renderHook(() => useShouldUseOverlayStyles(), {
92+
wrapper: createWrapper(
93+
createMessageContextValue({
94+
message: sharedMessage,
95+
messageOverlayId: 'message-overlay-first',
96+
}),
97+
),
98+
});
99+
100+
const second = renderHook(() => useShouldUseOverlayStyles(), {
101+
wrapper: createWrapper(
102+
createMessageContextValue({
103+
message: sharedMessage,
104+
messageOverlayId: 'message-overlay-second',
105+
}),
106+
),
107+
});
108+
109+
expect(first.result.current).toBe(false);
110+
expect(second.result.current).toBe(false);
111+
112+
act(() => {
113+
openOverlay('message-overlay-first');
114+
});
115+
116+
expect(first.result.current).toBe(true);
117+
expect(second.result.current).toBe(false);
118+
119+
first.unmount();
120+
second.unmount();
121+
});
122+
});

package/src/components/Message/hooks/useCreateMessageContext.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const useCreateMessageContext = ({
3434
lastGroupMessage,
3535
members,
3636
message,
37+
messageOverlayId,
3738
messageContentOrder,
3839
myMessageTheme,
3940
onLongPress,
@@ -86,6 +87,7 @@ export const useCreateMessageContext = ({
8687
lastGroupMessage,
8788
members,
8889
message,
90+
messageOverlayId,
8991
messageContentOrder,
9092
myMessageTheme,
9193
onLongPress,
@@ -117,6 +119,7 @@ export const useCreateMessageContext = ({
117119
lastGroupMessage,
118120
membersValue,
119121
myMessageThemeString,
122+
messageOverlayId,
120123
reactionsValue,
121124
stringifiedMessage,
122125
stringifiedQuotedMessage,

package/src/components/Message/hooks/useShouldUseOverlayStyles.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { useMessageContext } from '../../../contexts';
22
import { useIsOverlayActive } from '../../../state-store';
33

44
export const useShouldUseOverlayStyles = () => {
5-
const { message } = useMessageContext();
6-
const { active, closing } = useIsOverlayActive(message?.id);
5+
const { messageOverlayId } = useMessageContext();
6+
const { active, closing } = useIsOverlayActive(messageOverlayId);
77

88
return active && !closing;
99
};
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import React, { useEffect } from 'react';
2+
import { StyleSheet } from 'react-native';
3+
4+
import Animated, { LinearTransition, ZoomIn, ZoomOut } from 'react-native-reanimated';
5+
6+
import { textComposerStateSelector } from './utils/messageComposerSelectors';
7+
8+
import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer';
9+
import { useStateStore } from '../../hooks/useStateStore';
10+
import { primitives } from '../../theme';
11+
import { GiphyChip } from '../ui/GiphyChip';
12+
13+
export const CommandChip = () => {
14+
const messageComposer = useMessageComposer();
15+
const { textComposer, attachmentManager } = messageComposer;
16+
const { command } = useStateStore(textComposer.state, textComposerStateSelector);
17+
18+
useEffect(() => {
19+
if (attachmentManager.state.getLatestValue().attachments.length > 0) {
20+
textComposer.clearCommand();
21+
}
22+
}, [textComposer, attachmentManager]);
23+
24+
return command ? (
25+
<Animated.View
26+
entering={ZoomIn.duration(200)}
27+
exiting={ZoomOut.duration(200)}
28+
layout={LinearTransition.duration(200)}
29+
style={styles.giphyContainer}
30+
>
31+
<GiphyChip />
32+
</Animated.View>
33+
) : null;
34+
};
35+
36+
const styles = StyleSheet.create({
37+
giphyContainer: {
38+
padding: primitives.spacingSm,
39+
alignSelf: 'flex-end',
40+
},
41+
});

0 commit comments

Comments
 (0)