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.

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.

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));
};

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,
  };
};

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.

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
          additionalTextareaProps={{ onChange: handleInputChange }}
          getDefaultValue={getMainInputDraft}
          overrideSubmitHandler={overrideSubmitHandler}
          focus
        />
      </Window>
      <Thread
        additionalMessageInputProps={{
          additionalTextareaProps: { onChange: handleThreadInputChange },
          getDefaultValue: getThreadInputDraft,
          overrideSubmitHandler,
        }}
      />
    </>
  );
};

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

© Getstream.io, Inc. All Rights Reserved.