Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ If true, focuses the text input on component mount.
| ------- | ------- |
| boolean | false |

### getDefaultValue

Generates the default value for the underlying textarea element. The function's return value takes precedence before `additionalTextareaProps.defaultValue`.

| Type |
|---------------------------|
| () => string \| string[]) |

### grow

If true, expands the text input vertically for new lines.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
id: adding_messagelist_notification
sidebar_position: 6
sidebar_position: 7
title: Message List Notifications
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
---
id: persist_input_text_in_localstorage
sidebar_position: 6
slug: /guides/persist-input-text-in-localstorage/
title: Storing message drafts
---

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.


## Building the draft storage logic
Below, we have a simple logic to store all the message text drafts in a localStorage object under the key `@chat/drafts`.

```ts
const STORAGE_KEY = '@chat/drafts';

const getDrafts = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');

const removeDraft = (key: string) => {
const drafts = getDrafts();

if (drafts[key]) {
delete drafts[key];
localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts))
}
};

const updateDraft = (key: string, value: string) => {
const drafts = getDrafts();

if (!value) {
delete drafts[key];
} else {
drafts[key] = value
}

localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts))
}
```

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.

```ts
import { ChangeEvent, useCallback } from 'react';
import {
MessageToSend,
useChannelActionContext,
useChannelStateContext,
} from 'stream-chat-react';
import type {
Message
} from 'stream-chat';

const STORAGE_KEY = '@chat/drafts';

const getDrafts = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');

const removeDraft = (key: string) => {
const drafts = getDrafts();

if (drafts[key]) {
delete drafts[key];
localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts))
}
};

const updateDraft = (key: string, value: string) => {
const drafts = getDrafts();

if (!value) {
delete drafts[key];
} else {
drafts[key] = value
}

localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts))
}

// highlight-start
const useDraftAPI = () => {
const { channel, thread } = useChannelStateContext();
const { sendMessage } = useChannelActionContext();

const handleInputChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
updateDraft(channel.cid, e.target.value);
}, [channel.cid])

const handleThreadInputChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
if (!thread) return;
updateDraft(`${channel.cid}:${thread.id}`, e.target.value);
}, [channel.cid, thread]);

const getMainInputDraft = useCallback(() => {
const drafts = getDrafts();
return drafts[channel.cid] || '';
}, [channel.cid]);

const getThreadInputDraft = useCallback(() => {
if (!thread) return;
const drafts = getDrafts();
return drafts[`${channel.cid}:${thread.id}`] || '';
}, [channel.cid, thread]);

const overrideSubmitHandler = useCallback(
async (message: MessageToSend, channelCid: string, customMessageData?: Partial<Message>,) => {
await sendMessage(message, customMessageData);
const key = message.parent ? `${channelCid}:${message.parent.id}` : channelCid;
removeDraft(key);
}, [sendMessage])

return {
getMainInputDraft,
getThreadInputDraft,
handleInputChange,
handleThreadInputChange,
overrideSubmitHandler,
}
}
// highlight-end
```

## Plugging it in

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`.

```tsx
import { ChannelFilters, ChannelOptions, ChannelSort, StreamChat } from 'stream-chat';
import { useDraftAPI } from './useDraftAPI';
import type { StreamChatGenerics } from './types';

const ChannelWindow = () => {
const {
getMainInputDraft,
getThreadInputDraft,
handleInputChange,
handleThreadInputChange,
overrideSubmitHandler,
} = useDraftAPI()

return (
<>
<Window>
<TruncateButton/>
<ChannelHeader/>
<MessageList/>
<MessageInput
// highlight-start
additionalTextareaProps={{onChange: handleInputChange}}
getDefaultValue={getMainInputDraft}
overrideSubmitHandler={overrideSubmitHandler}
// highlight-end
focus
/>
</Window>
<Thread additionalMessageInputProps={{
// highlight-start
additionalTextareaProps: {onChange: handleThreadInputChange},
getDefaultValue: getThreadInputDraft,
overrideSubmitHandler,
// highlight-end
}}/>
</>
)
}

// In your application you will probably initiate the client in a React effect.
const chatClient = StreamChat.getInstance<StreamChatGenerics>('<YOUR_API_KEY>');

// User your own filters, options, sort if needed
const filters: ChannelFilters = { type: 'messaging', members: { $in: ['<YOUR_USER_ID>'] } };
const options: ChannelOptions = { state: true, presence: true, limit: 10 };
const sort: ChannelSort = { last_message_at: -1, updated_at: -1 };

const App = () => {
return (
<Chat client={chatClient}>
<ChannelList filters={filters} sort={sort} options={options} showChannelSearch/>
<Channel>
<ChannelWindow/>
</Channel>
</Chat>
);
};
```

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.
2 changes: 2 additions & 0 deletions src/components/MessageInput/MessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export type MessageInputProps<
) => void;
/** If true, focuses the text input on component mount */
focus?: boolean;
/** Generates the default value for the underlying textarea element. The function's return value takes precedence before additionalTextareaProps.defaultValue. */
getDefaultValue?: () => string | string[];
/** If true, expands the text input vertically for new lines */
grow?: boolean;
/** 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) */
Expand Down
16 changes: 16 additions & 0 deletions src/components/MessageInput/__tests__/MessageInput.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,22 @@ function axeNoViolations(container) {
});
});

it('should prefer value from getDefaultValue before additionalTextareaProps.defaultValue', async () => {
const defaultValue = nanoid();
const generatedDefaultValue = nanoid();
const getDefaultValue = () => generatedDefaultValue;
await renderComponent({
messageInputProps: {
additionalTextareaProps: { defaultValue },
getDefaultValue,
},
});
await waitFor(() => {
const textarea = screen.queryByDisplayValue(generatedDefaultValue);
expect(textarea).toBeInTheDocument();
});
});

it('Should shift focus to the textarea if the `focus` prop is true', async () => {
const { container } = await renderComponent({
messageInputProps: {
Expand Down
4 changes: 2 additions & 2 deletions src/components/MessageInput/hooks/useMessageInputState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,13 +363,13 @@ export const useMessageInputState = <
MessageInputHookProps<StreamChatGenerics> &
CommandsListState &
MentionsListState => {
const { additionalTextareaProps, closeEmojiPickerOnClick, message } = props;
const { additionalTextareaProps, closeEmojiPickerOnClick, getDefaultValue, message } = props;

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

const defaultValue = additionalTextareaProps?.defaultValue;
const defaultValue = getDefaultValue?.() || additionalTextareaProps?.defaultValue;
const initialStateValue =
message ||
((Array.isArray(defaultValue)
Expand Down