Skip to content

Commit f61374f

Browse files
authored
fix: commands flow when interacting with other composer elements (#3570)
## 🎯 Goal This PR is an extension of our commands flow to better reflect what's allowed and what's not on our backend. LLC PR with better explanation: GetStream/stream-chat-js#1723 General points: - Commands are disabled in certain scenarios: - When a quoted message is available, only `giphy` commands are allowed - When we're in an editing state, no commands are allowed (this is ignored server-side) - Adding a command when we have `composer` state already (specifically `text` and `attachments`), removes these but keeps a snapshot of the state so that if we remove the command we go back to the previous state - Adding `editing`/`quoted_message` state on already existing commands for which this is not allowed will remove them Trying to attach commands in a non-allowed state will also fire a notification so that the UI can react accordingly. ## 🛠 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 2419df8 commit f61374f

14 files changed

Lines changed: 427 additions & 30 deletions

File tree

examples/SampleApp/yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8291,10 +8291,10 @@ stream-chat-react-native-core@8.1.0:
82918291
version "0.0.0"
82928292
uid ""
82938293

8294-
stream-chat@^9.42.1:
8295-
version "9.42.1"
8296-
resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.42.1.tgz#8b6aa4e3e73a39ed07bb2a4f2a6829ba9354567a"
8297-
integrity sha512-o+9wQO4Ruu1A48T0IrX9ZH8+9F5xPgGLPvflaswaPeLyIZXcy8bsQdcT/HSrPmT7gs0WGD3qcbXaAJU5lMQezQ==
8294+
stream-chat@^9.43.0:
8295+
version "9.43.0"
8296+
resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.43.0.tgz#216a80abadea83dcee6fb339b76035b26af2beb5"
8297+
integrity sha512-gc12LZTmRWvSi6EjnMK7Y+D8xOQIouVUO2flUShazG/NqVccJhXYphQ96PzK7Wym+5wwwitTaJqq0m/1VUPBCA==
82988298
dependencies:
82998299
"@types/jsonwebtoken" "^9.0.8"
83008300
"@types/ws" "^8.5.14"

package/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
"path": "0.12.7",
8484
"react-native-markdown-package": "1.8.2",
8585
"react-native-url-polyfill": "^2.0.0",
86-
"stream-chat": "^9.42.1",
86+
"stream-chat": "^9.43.0",
8787
"use-sync-external-store": "^1.5.0"
8888
},
8989
"peerDependencies": {

package/src/components/AttachmentPicker/components/AttachmentPickerContent.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,13 @@ export const AttachmentCommandNativePickerItem = ({ item }: { item: CommandSugge
5858
const { close } = useBottomSheetContext();
5959

6060
const handlePress = useCallback(() => {
61+
if (messageComposer.isCommandDisabled(item)) {
62+
return;
63+
}
64+
6165
textComposer.setCommand(item);
6266
close(() => inputBoxRef.current?.focus());
63-
}, [textComposer, item, close, inputBoxRef]);
67+
}, [messageComposer, item, textComposer, close, inputBoxRef]);
6468

6569
return <AttachmentCommandPickerItemUI item={item} onPress={handlePress} />;
6670
};
@@ -73,9 +77,13 @@ export const AttachmentCommandPickerItem = ({ item }: { item: CommandSuggestion
7377
const { inputBoxRef } = useMessageInputContext();
7478

7579
const handlePress = useCallback(() => {
80+
if (messageComposer.isCommandDisabled(item)) {
81+
return;
82+
}
83+
7684
textComposer.setCommand(item);
7785
inputBoxRef.current?.focus();
78-
}, [textComposer, item, inputBoxRef]);
86+
}, [messageComposer, item, textComposer, inputBoxRef]);
7987

8088
if (disableAttachmentPicker) {
8189
return <AttachmentCommandNativePickerItem item={item} />;

package/src/components/AttachmentPicker/components/AttachmentTypePickerButton.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { AttachmentCommandPicker } from './AttachmentPickerContent';
99
import {
1010
useAttachmentPickerContext,
1111
useChannelContext,
12-
useMessageComposer,
1312
useMessageInputContext,
1413
useMessagesContext,
1514
useOwnCapabilitiesContext,
@@ -182,7 +181,6 @@ export const PollPickerButton = () => {
182181

183182
export const CommandsPickerButton = () => {
184183
const [showCommandsSheet, setShowCommandsSheet] = useState(false);
185-
const messageComposer = useMessageComposer();
186184
const { hasCommands } = useMessageInputContext();
187185
const { attachmentPickerStore, disableAttachmentPicker } = useAttachmentPickerContext();
188186
const { selectedPicker } = useAttachmentPickerState();
@@ -197,7 +195,7 @@ export const CommandsPickerButton = () => {
197195

198196
const onClose = useStableCallback(() => setShowCommandsSheet(false));
199197

200-
return hasCommands && !messageComposer.editedMessage ? (
198+
return hasCommands ? (
201199
<>
202200
<AttachmentTypePickerButton
203201
testID='commands-touchable'
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import React from 'react';
2+
3+
import { fireEvent, render, screen } from '@testing-library/react-native';
4+
import type { CommandSuggestion } from 'stream-chat';
5+
6+
import {
7+
AttachmentCommandNativePickerItem,
8+
AttachmentCommandPickerItem,
9+
} from '../AttachmentPickerContent';
10+
11+
jest.mock('stream-chat', () => ({
12+
CommandSearchSource: jest.fn(() => ({
13+
query: jest.fn(() => ({ items: [] })),
14+
})),
15+
}));
16+
17+
jest.mock('../AttachmentMediaPicker/AttachmentMediaPicker', () => ({
18+
AttachmentMediaPicker: () => null,
19+
}));
20+
21+
const mockClose = jest.fn((callback?: () => void) => callback?.());
22+
const mockFocus = jest.fn();
23+
const mockIsCommandDisabled = jest.fn();
24+
const mockSetCommand = jest.fn();
25+
26+
jest.mock('../../../../contexts', () => ({
27+
useAttachmentPickerContext: jest.fn(() => ({
28+
disableAttachmentPicker: false,
29+
})),
30+
useBottomSheetContext: jest.fn(() => ({
31+
close: mockClose,
32+
})),
33+
useMessageComposer: jest.fn(() => ({
34+
isCommandDisabled: mockIsCommandDisabled,
35+
textComposer: { setCommand: mockSetCommand },
36+
})),
37+
useMessageInputContext: jest.fn(() => ({
38+
inputBoxRef: { current: { focus: mockFocus } },
39+
})),
40+
}));
41+
42+
jest.mock('../../../../contexts/themeContext/ThemeContext', () => ({
43+
useTheme: jest.fn(() => ({
44+
theme: {
45+
semantics: {
46+
backgroundUtilityPressed: '#f5f5f5',
47+
},
48+
},
49+
})),
50+
}));
51+
52+
jest.mock('../../../../hooks', () => ({
53+
useAttachmentPickerState: jest.fn(() => ({ selectedPicker: 'images' })),
54+
useStableCallback: (callback: unknown) => callback,
55+
}));
56+
57+
jest.mock('../../../AutoCompleteInput/AutoCompleteSuggestionItem', () => {
58+
const { Text } = require('react-native');
59+
60+
return {
61+
CommandSuggestionItem: ({ name }: CommandSuggestion) => <Text>{name}</Text>,
62+
};
63+
});
64+
65+
const command = {
66+
args: '',
67+
id: 'ban',
68+
name: 'ban',
69+
set: 'moderation_set',
70+
} as CommandSuggestion;
71+
72+
describe('AttachmentPickerContent commands', () => {
73+
beforeEach(() => {
74+
mockClose.mockClear();
75+
mockFocus.mockClear();
76+
mockIsCommandDisabled.mockReset();
77+
mockSetCommand.mockClear();
78+
});
79+
80+
it('does not focus the input when a disabled command is pressed', () => {
81+
mockIsCommandDisabled.mockReturnValue(true);
82+
83+
render(<AttachmentCommandPickerItem item={command} />);
84+
85+
fireEvent.press(screen.getByText('ban'));
86+
87+
expect(mockIsCommandDisabled).toHaveBeenCalledWith(command);
88+
expect(mockSetCommand).not.toHaveBeenCalled();
89+
expect(mockFocus).not.toHaveBeenCalled();
90+
});
91+
92+
it('does not close the picker or focus the input when a disabled command is pressed in native picker mode', () => {
93+
mockIsCommandDisabled.mockReturnValue(true);
94+
95+
render(<AttachmentCommandNativePickerItem item={command} />);
96+
97+
fireEvent.press(screen.getByText('ban'));
98+
99+
expect(mockIsCommandDisabled).toHaveBeenCalledWith(command);
100+
expect(mockSetCommand).not.toHaveBeenCalled();
101+
expect(mockClose).not.toHaveBeenCalled();
102+
expect(mockFocus).not.toHaveBeenCalled();
103+
});
104+
});

package/src/components/AutoCompleteInput/AutoCompleteInput.tsx

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,15 @@ import {
1717
useChannelContext,
1818
} from '../../contexts/channelContext/ChannelContext';
1919
import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer';
20-
import {
21-
MessageInputContextValue,
22-
useMessageInputContext,
23-
} from '../../contexts/messageInputContext/MessageInputContext';
20+
import type { MessageInputContextValue } from '../../contexts/messageInputContext/MessageInputContext';
21+
import { useMessageInputContext } from '../../contexts/messageInputContext/MessageInputContext';
2422
import { useTheme } from '../../contexts/themeContext/ThemeContext';
2523
import {
2624
TranslationContextValue,
2725
useTranslationContext,
2826
} from '../../contexts/translationContext/TranslationContext';
2927

28+
import { useStableCallback } from '../../hooks';
3029
import { useStateStore } from '../../hooks/useStateStore';
3130
import { useCooldownRemaining } from '../MessageInput/hooks/useCooldownRemaining';
3231

@@ -45,6 +44,19 @@ const TextInputRenderer = React.forwardRef<RNTextInput, AnimatedTextInputRendere
4544

4645
const AnimatedTextInputRenderer = Animated.createAnimatedComponent(TextInputRenderer);
4746

47+
const setRef = <T,>(ref: React.Ref<T> | undefined, value: T | null) => {
48+
if (!ref) {
49+
return;
50+
}
51+
52+
if (typeof ref === 'function') {
53+
ref(value);
54+
return;
55+
}
56+
57+
(ref as React.RefObject<T | null>).current = value;
58+
};
59+
4860
type AutoCompleteInputPropsWithContext = TextInputProps &
4961
Pick<ChannelContextValue, 'channel'> &
5062
Pick<MessageInputContextValue, 'setInputBoxRef'> &
@@ -109,6 +121,30 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext)
109121
setLocalText(text);
110122
}, [text]);
111123

124+
const clearState = useCallback(() => {
125+
setLocalText('');
126+
}, []);
127+
128+
const restoreState = useStableCallback((restoredText: string) => {
129+
setLocalText(restoredText);
130+
});
131+
132+
const setExtendedInputRef = useCallback(
133+
(ref: RNTextInput | null) => {
134+
if (!ref) {
135+
setRef(setInputBoxRef, null);
136+
return;
137+
}
138+
139+
const inputBoxRef = Object.assign(ref, {
140+
clearState,
141+
restoreState,
142+
});
143+
setRef(setInputBoxRef, inputBoxRef);
144+
},
145+
[clearState, restoreState, setInputBoxRef],
146+
);
147+
112148
const handleSelectionChange = useCallback(
113149
(e: TextInputSelectionChangeEvent) => {
114150
const { selection } = e.nativeEvent;
@@ -161,7 +197,7 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext)
161197
onSelectionChange={handleSelectionChange}
162198
placeholder={placeholderText}
163199
placeholderTextColor={semantics.inputTextPlaceholder}
164-
ref={setInputBoxRef}
200+
ref={setExtendedInputRef}
165201
style={[
166202
styles.inputBox,
167203
{

package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import React, { useCallback, useMemo } from 'react';
22
import { Pressable, StyleSheet, Text, View } from 'react-native';
33

4-
import { CommandSuggestion, TextComposerSuggestion, UserSuggestion } from 'stream-chat';
4+
import type { CommandSuggestion, TextComposerSuggestion, UserSuggestion } from 'stream-chat';
55

66
import { AutoCompleteSuggestionCommandIcon } from './AutoCompleteSuggestionCommandIcon';
77

8+
import { useIsCommandDisabled } from '../../contexts/messageInputContext/hooks/useIsCommandDisabled';
89
import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer';
910
import { useTheme } from '../../contexts/themeContext/ThemeContext';
1011
import { primitives } from '../../theme';
@@ -81,11 +82,17 @@ export const CommandSuggestionItem = (item: CommandSuggestion) => {
8182
} = useTheme();
8283
const styles = useStyles();
8384

85+
const isDisabled = useIsCommandDisabled(item);
86+
8487
return (
8588
<View style={[styles.commandContainer, commandContainer]}>
8689
{name ? <AutoCompleteSuggestionCommandIcon name={name} /> : null}
8790
<Text
88-
style={[styles.title, { color: semantics.textPrimary }, title]}
91+
style={[
92+
styles.title,
93+
{ color: isDisabled ? semantics.textTertiary : semantics.textPrimary },
94+
title,
95+
]}
8996
testID='commands-item-title'
9097
>
9198
{(name || '').replace(/^\w/, (char) => char.toUpperCase())}

package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import React from 'react';
22

3+
import type { TextInput } from 'react-native';
4+
35
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
46
import type { Channel as ChannelType, StreamChat } from 'stream-chat';
57

68
import { OverlayProvider } from '../../../contexts';
9+
import type { InputBoxRef } from '../../../contexts/messageInputContext/MessageInputContext';
710
import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels';
811
import type { ChannelProps } from '../../Channel/Channel';
912
import { Channel } from '../../Channel/Channel';
@@ -123,6 +126,50 @@ describe('AutoCompleteInput', () => {
123126
});
124127
});
125128

129+
it('should expose imperative state handlers on the input ref', async () => {
130+
let inputRef: InputBoxRef | null = null;
131+
const text = 'hello';
132+
const channelProps = {
133+
channel,
134+
setInputRef: (ref: TextInput | null) => {
135+
inputRef = ref as InputBoxRef | null;
136+
},
137+
};
138+
const props = {};
139+
140+
renderComponent({ channelProps, client, props });
141+
142+
await waitFor(() => {
143+
expect(inputRef?.clearState).toBeTruthy();
144+
expect(inputRef?.restoreState).toBeTruthy();
145+
});
146+
147+
act(() => {
148+
fireEvent.changeText(screen.getByTestId('auto-complete-text-input'), text);
149+
});
150+
151+
await waitFor(() => {
152+
expect(screen.getByTestId('auto-complete-text-input').props.value).toBe(text);
153+
});
154+
155+
act(() => {
156+
inputRef?.clearState();
157+
});
158+
159+
await waitFor(() => {
160+
expect(screen.getByTestId('auto-complete-text-input').props.value).toBe('');
161+
expect(channel.messageComposer.textComposer.text).toBe(text);
162+
});
163+
164+
act(() => {
165+
inputRef?.restoreState(text);
166+
});
167+
168+
await waitFor(() => {
169+
expect(screen.getByTestId('auto-complete-text-input').props.value).toBe(text);
170+
});
171+
});
172+
126173
it('should call the textComposer setSelection when the onSelectionChange is triggered', async () => {
127174
const { textComposer } = channel.messageComposer;
128175

package/src/components/ui/GiphyChip.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const GiphyChip = () => {
2727

2828
const onPressHandler = () => {
2929
textComposer.clearCommand();
30-
messageComposer?.restore();
30+
// messageComposer?.restore();
3131
};
3232

3333
return (

0 commit comments

Comments
 (0)