Skip to content

Commit 9316bdf

Browse files
authored
feat: allow to retrieve the default message input value dynamically (#2007)
* feat: allow to retrieve the default message input value dynamically * docs: document addition of getDefaultValue to MessageInputProps * docs: provide recipe to store message text drafts
1 parent 569c53d commit 9316bdf

File tree

6 files changed

+215
-3
lines changed

6 files changed

+215
-3
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/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)