Skip to content

Commit dd201bf

Browse files
authored
Merge pull request #2008 from GetStream/develop
v10.8.0
2 parents 835048a + bd81a63 commit dd201bf

File tree

8 files changed

+281
-15
lines changed

8 files changed

+281
-15
lines changed

docusaurus/docs/React/components/message-input-components/message-input.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ If true, focuses the text input on component mount.
108108
| ------- | ------- |
109109
| boolean | false |
110110

111+
### getDefaultValue
112+
113+
Generates the default value for the underlying textarea element. The function's return value takes precedence before `additionalTextareaProps.defaultValue`.
114+
115+
| Type |
116+
|---------------------------|
117+
| () => string \| string[]) |
118+
111119
### grow
112120

113121
If true, expands the text input vertically for new lines.

docusaurus/docs/React/guides/customization/adding-notification.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
id: adding_messagelist_notification
3-
sidebar_position: 6
3+
sidebar_position: 7
44
title: Message List Notifications
55
---
66

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
---
2+
id: persist_input_text_in_localstorage
3+
sidebar_position: 6
4+
slug: /guides/persist-input-text-in-localstorage/
5+
title: Storing message drafts
6+
---
7+
8+
In this recipe, we would like to demonstrate how you can start storing unsent user's messages as drafts. The whole implementation turns around the use of `MessageInput`'s prop `getDefaultValue` and custom change event handler. We will store the messages in localStorage.
9+
10+
11+
## Building the draft storage logic
12+
Below, we have a simple logic to store all the message text drafts in a localStorage object under the key `@chat/drafts`.
13+
14+
```ts
15+
const STORAGE_KEY = '@chat/drafts';
16+
17+
const getDrafts = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
18+
19+
const removeDraft = (key: string) => {
20+
const drafts = getDrafts();
21+
22+
if (drafts[key]) {
23+
delete drafts[key];
24+
localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts))
25+
}
26+
};
27+
28+
const updateDraft = (key: string, value: string) => {
29+
const drafts = getDrafts();
30+
31+
if (!value) {
32+
delete drafts[key];
33+
} else {
34+
drafts[key] = value
35+
}
36+
37+
localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts))
38+
}
39+
```
40+
41+
On top of this logic we build a hook that exposes the change handler functions for both thread and main `MessageInput` components as well as functions for `MessageInput`'s `getDefaultValue` prop. We also have to override the `MessageInput`'s default submit handler, because we want to remove the draft from storage when a message is sent.
42+
43+
```ts
44+
import { ChangeEvent, useCallback } from 'react';
45+
import {
46+
MessageToSend,
47+
useChannelActionContext,
48+
useChannelStateContext,
49+
} from 'stream-chat-react';
50+
import type {
51+
Message
52+
} from 'stream-chat';
53+
54+
const STORAGE_KEY = '@chat/drafts';
55+
56+
const getDrafts = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
57+
58+
const removeDraft = (key: string) => {
59+
const drafts = getDrafts();
60+
61+
if (drafts[key]) {
62+
delete drafts[key];
63+
localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts))
64+
}
65+
};
66+
67+
const updateDraft = (key: string, value: string) => {
68+
const drafts = getDrafts();
69+
70+
if (!value) {
71+
delete drafts[key];
72+
} else {
73+
drafts[key] = value
74+
}
75+
76+
localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts))
77+
}
78+
79+
// highlight-start
80+
const useDraftAPI = () => {
81+
const { channel, thread } = useChannelStateContext();
82+
const { sendMessage } = useChannelActionContext();
83+
84+
const handleInputChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
85+
updateDraft(channel.cid, e.target.value);
86+
}, [channel.cid])
87+
88+
const handleThreadInputChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
89+
if (!thread) return;
90+
updateDraft(`${channel.cid}:${thread.id}`, e.target.value);
91+
}, [channel.cid, thread]);
92+
93+
const getMainInputDraft = useCallback(() => {
94+
const drafts = getDrafts();
95+
return drafts[channel.cid] || '';
96+
}, [channel.cid]);
97+
98+
const getThreadInputDraft = useCallback(() => {
99+
if (!thread) return;
100+
const drafts = getDrafts();
101+
return drafts[`${channel.cid}:${thread.id}`] || '';
102+
}, [channel.cid, thread]);
103+
104+
const overrideSubmitHandler = useCallback(
105+
async (message: MessageToSend, channelCid: string, customMessageData?: Partial<Message>,) => {
106+
await sendMessage(message, customMessageData);
107+
const key = message.parent ? `${channelCid}:${message.parent.id}` : channelCid;
108+
removeDraft(key);
109+
}, [sendMessage])
110+
111+
return {
112+
getMainInputDraft,
113+
getThreadInputDraft,
114+
handleInputChange,
115+
handleThreadInputChange,
116+
overrideSubmitHandler,
117+
}
118+
}
119+
// highlight-end
120+
```
121+
122+
## Plugging it in
123+
124+
Now it is time to access the API in our React component. The component has to be a descendant of `Channel` component, because `useDraftAPI` accesses the `ChannelStateContext` and `ChannelActionContext` through corresponding consumers. In our example we call this component `ChannelWindow`.
125+
126+
```tsx
127+
import { ChannelFilters, ChannelOptions, ChannelSort, StreamChat } from 'stream-chat';
128+
import { useDraftAPI } from './useDraftAPI';
129+
import type { StreamChatGenerics } from './types';
130+
131+
const ChannelWindow = () => {
132+
const {
133+
getMainInputDraft,
134+
getThreadInputDraft,
135+
handleInputChange,
136+
handleThreadInputChange,
137+
overrideSubmitHandler,
138+
} = useDraftAPI()
139+
140+
return (
141+
<>
142+
<Window>
143+
<TruncateButton/>
144+
<ChannelHeader/>
145+
<MessageList/>
146+
<MessageInput
147+
// highlight-start
148+
additionalTextareaProps={{onChange: handleInputChange}}
149+
getDefaultValue={getMainInputDraft}
150+
overrideSubmitHandler={overrideSubmitHandler}
151+
// highlight-end
152+
focus
153+
/>
154+
</Window>
155+
<Thread additionalMessageInputProps={{
156+
// highlight-start
157+
additionalTextareaProps: {onChange: handleThreadInputChange},
158+
getDefaultValue: getThreadInputDraft,
159+
overrideSubmitHandler,
160+
// highlight-end
161+
}}/>
162+
</>
163+
)
164+
}
165+
166+
// In your application you will probably initiate the client in a React effect.
167+
const chatClient = StreamChat.getInstance<StreamChatGenerics>('<YOUR_API_KEY>');
168+
169+
// User your own filters, options, sort if needed
170+
const filters: ChannelFilters = { type: 'messaging', members: { $in: ['<YOUR_USER_ID>'] } };
171+
const options: ChannelOptions = { state: true, presence: true, limit: 10 };
172+
const sort: ChannelSort = { last_message_at: -1, updated_at: -1 };
173+
174+
const App = () => {
175+
return (
176+
<Chat client={chatClient}>
177+
<ChannelList filters={filters} sort={sort} options={options} showChannelSearch/>
178+
<Channel>
179+
<ChannelWindow/>
180+
</Channel>
181+
</Chat>
182+
);
183+
};
184+
```
185+
186+
Now once you start typing, you should be able to see the drafts in the `localStorage` under the key `@chat/drafts`. Despite changing channels or threads, the unsent message text should be kept in the textarea.

src/components/AutoCompleteTextarea/Textarea.jsx

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import {
1313
triggerPropsCheck,
1414
} from './utils';
1515

16-
import { CommandItem } from '../CommandItem/CommandItem';
17-
import { UserItem } from '../UserItem/UserItem';
16+
import { CommandItem } from '../CommandItem';
17+
import { UserItem } from '../UserItem';
1818

1919
export class ReactTextareaAutocomplete extends React.Component {
2020
static defaultProps = {
@@ -687,6 +687,16 @@ export class ReactTextareaAutocomplete extends React.Component {
687687

688688
render() {
689689
const { className, containerClassName, containerStyle, style } = this.props;
690+
const {
691+
onBlur,
692+
onChange,
693+
onClick,
694+
onFocus,
695+
onKeyDown,
696+
onScroll,
697+
onSelect,
698+
...restAdditionalTextareaProps
699+
} = this.props.additionalTextareaProps || {};
690700

691701
let { maxRows } = this.props;
692702

@@ -711,20 +721,41 @@ export class ReactTextareaAutocomplete extends React.Component {
711721
{...this._cleanUpProps()}
712722
className={clsx('rta__textarea', className)}
713723
maxRows={maxRows}
714-
onBlur={this._onClickAndBlurHandler}
715-
onChange={this._changeHandler}
716-
onClick={this._onClickAndBlurHandler}
717-
onFocus={this.props.onFocus}
718-
onKeyDown={this._handleKeyDown}
719-
onScroll={this._onScrollHandler}
720-
onSelect={this._selectHandler}
724+
onBlur={(e) => {
725+
this._onClickAndBlurHandler(e);
726+
onBlur?.(e);
727+
}}
728+
onChange={(e) => {
729+
this._changeHandler(e);
730+
onChange?.(e);
731+
}}
732+
onClick={(e) => {
733+
this._onClickAndBlurHandler(e);
734+
onClick?.(e);
735+
}}
736+
onFocus={(e) => {
737+
this.props.onFocus?.(e);
738+
onFocus?.(e);
739+
}}
740+
onKeyDown={(e) => {
741+
this._handleKeyDown(e);
742+
onKeyDown?.(e);
743+
}}
744+
onScroll={(e) => {
745+
this._onScrollHandler(e);
746+
onScroll?.(e);
747+
}}
748+
onSelect={(e) => {
749+
this._selectHandler(e);
750+
onSelect?.(e);
751+
}}
721752
ref={(ref) => {
722753
this.props?.innerRef(ref);
723754
this.textareaRef = ref;
724755
}}
725756
style={style}
726757
value={value}
727-
{...this.props.additionalTextareaProps}
758+
{...restAdditionalTextareaProps}
728759
defaultValue={undefined}
729760
/>
730761
</div>

src/components/ChannelList/utils.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
1-
import type { Channel, StreamChat } from 'stream-chat';
1+
import type { Channel, QueryChannelAPIResponse, StreamChat } from 'stream-chat';
22
import uniqBy from 'lodash.uniqby';
33

44
import type { DefaultStreamChatGenerics } from '../../types/types';
55

6+
/**
7+
* prevent from duplicate invocation of channel.watch()
8+
* when events 'notification.message_new' and 'notification.added_to_channel' arrive at the same time
9+
*/
10+
const WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL: Record<
11+
string,
12+
Promise<QueryChannelAPIResponse> | undefined
13+
> = {};
14+
15+
/**
16+
* Calls channel.watch() if it was not already recently called. Waits for watch promise to resolve even if it was invoked previously.
17+
* @param client
18+
* @param type
19+
* @param id
20+
*/
621
export const getChannel = async <
722
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
823
>(
@@ -11,7 +26,15 @@ export const getChannel = async <
1126
id: string,
1227
) => {
1328
const channel = client.channel(type, id);
14-
await channel.watch();
29+
const queryPromise = WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[channel.cid];
30+
if (queryPromise) {
31+
await queryPromise;
32+
} else {
33+
WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[channel.cid] = channel.watch();
34+
await WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[channel.cid];
35+
WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[channel.cid] = undefined;
36+
}
37+
1538
return channel;
1639
};
1740

src/components/MessageInput/MessageInput.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export type MessageInputProps<
5151
) => void;
5252
/** If true, focuses the text input on component mount */
5353
focus?: boolean;
54+
/** Generates the default value for the underlying textarea element. The function's return value takes precedence before additionalTextareaProps.defaultValue. */
55+
getDefaultValue?: () => string | string[];
5456
/** If true, expands the text input vertically for new lines */
5557
grow?: boolean;
5658
/** Custom UI component handling how the message input is rendered, defaults to and accepts the same props as [MessageInputFlat](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/MessageInputFlat.tsx) */

src/components/MessageInput/__tests__/MessageInput.test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,22 @@ function axeNoViolations(container) {
189189
});
190190
});
191191

192+
it('should prefer value from getDefaultValue before additionalTextareaProps.defaultValue', async () => {
193+
const defaultValue = nanoid();
194+
const generatedDefaultValue = nanoid();
195+
const getDefaultValue = () => generatedDefaultValue;
196+
await renderComponent({
197+
messageInputProps: {
198+
additionalTextareaProps: { defaultValue },
199+
getDefaultValue,
200+
},
201+
});
202+
await waitFor(() => {
203+
const textarea = screen.queryByDisplayValue(generatedDefaultValue);
204+
expect(textarea).toBeInTheDocument();
205+
});
206+
});
207+
192208
it('Should shift focus to the textarea if the `focus` prop is true', async () => {
193209
const { container } = await renderComponent({
194210
messageInputProps: {

src/components/MessageInput/hooks/useMessageInputState.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,13 +363,13 @@ export const useMessageInputState = <
363363
MessageInputHookProps<StreamChatGenerics> &
364364
CommandsListState &
365365
MentionsListState => {
366-
const { additionalTextareaProps, closeEmojiPickerOnClick, message } = props;
366+
const { additionalTextareaProps, closeEmojiPickerOnClick, getDefaultValue, message } = props;
367367

368368
const { channelCapabilities = {}, channelConfig } = useChannelStateContext<StreamChatGenerics>(
369369
'useMessageInputState',
370370
);
371371

372-
const defaultValue = additionalTextareaProps?.defaultValue;
372+
const defaultValue = getDefaultValue?.() || additionalTextareaProps?.defaultValue;
373373
const initialStateValue =
374374
message ||
375375
((Array.isArray(defaultValue)

0 commit comments

Comments
 (0)