Inline message editing

This recipe edits a message where it sits in the message list, instead of in the composer at the bottom of the channel.

Note:

What you'll build

  • Editing a message in place in the message list
  • An editor card with Cancel and Save buttons
  • Inline editing of replies in a thread
  • The composer's unsent text left as it was

Preview

The user's message open in an inline editor, a white card with a blue border that holds the input, the keyboard hint, and Cancel and Save, while the composer at the bottom still holds a message the user was writing

Approach: Replace one part. Choosing where to start explains the approaches.

Starts from: the base app. The page shows only what the recipe adds.

By default, Edit Message loads the message into the composer the user types in, channel.messageComposer or thread.messageComposer, and the edit replaces what was there until the user saves or cancels. An inline editor uses an edit composer instead: a second composer whose composition context is the message. Editing a message in an edit composer introduces it, and Message composer compares the two.

Best practices

  • Keep the edit composer outside the message's component. The message list re-renders and remounts messages, and an edit held there would be lost with them.
  • Edit one message at a time, and clear the previous edit composer when the user starts another edit.
  • Keep the edit type on the replaced action, so the base filter still shows it only to users who can edit the message.
  • Save with useMessageComposerSubmitFn(), so your Save button does what the checkmark and the Enter key do.
  • Let Esc close an open suggestion list or command first, and cancel the edit only after that.

An Edit action that opens the editor

The default Edit action loads the message into the channel's or thread's composer. This one hands the message to startEditing, from the recipe's own context (next step), and keeps the rest of the default action set:

src/InlineEditAction.tsx
import type { MessageActionSetItem } from "stream-chat-react";
import {
  ContextMenuButton,
  defaultMessageActionSet,
  MessageActions,
  useComponentContextIcons,
  useContextMenuContext,
  useMessageContext,
  useThreadContext,
} from "stream-chat-react";
import { useInlineEdit } from "./InlineEditContext";

const InlineEditAction = () => {
  const { IconEdit } = useComponentContextIcons();
  const { closeMenu } = useContextMenuContext();
  const { message } = useMessageContext();
  const isInThread = !!useThreadContext();
  const { startEditing } = useInlineEdit();

  return (
    <ContextMenuButton
      Icon={IconEdit}
      onClick={() => {
        startEditing(message, isInThread);
        closeMenu();
      }}
    >
      Edit Message
    </ContextMenuButton>
  );
};

// Declared once: the base filter recomputes whenever it gets a new list. The item keeps the
// `edit` type, so the menu still shows it only to users who can edit the message. The "more"
// button has no type, hence the `in` check.
const messageActionSet: MessageActionSetItem[] = defaultMessageActionSet.map(
  (item) =>
    "type" in item && item.type === "edit"
      ? { ...item, Component: InlineEditAction }
      : item,
);

export const InlineEditMessageActions = () => (
  <MessageActions messageActionSet={messageActionSet} />
);

Custom message actions replaces and adds actions the same way.

An edit composer for the message

InlineEditProvider holds the edit in progress: the message's id, whether it was opened in a thread's list, and the edit composer. startEditing creates the composer with the message as both its composition and its compositionContext. The composer then holds the message as editedMessage, and MessageComposer.update() saves it.

src/InlineEditContext.tsx
import type { PropsWithChildren } from "react";
import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import type { LocalMessage, MessageComposerState } from "stream-chat";
import { MessageComposer as MessageComposerController } from "stream-chat";
import { useChatContext } from "stream-chat-react";

/** The message being edited, the list it is edited in, and the composer that holds the edit. */
export type InlineEdit = {
  composer: MessageComposerController;
  isInThread: boolean;
  messageId: string;
};

type InlineEditContextValue = {
  edit: InlineEdit | null;
  startEditing: (message: LocalMessage, isInThread: boolean) => void;
};

const InlineEditContext = createContext<InlineEditContextValue>({
  edit: null,
  startEditing: () => undefined,
});

export const useInlineEdit = () => useContext(InlineEditContext);

const editedMessageSelector = (state: MessageComposerState) => ({
  editedMessage: state.editedMessage,
});

/** Owns the edit composer: at most one message is edited at a time. */
export const InlineEditProvider = ({ children }: PropsWithChildren) => {
  const { client } = useChatContext();
  const [edit, setEdit] = useState<InlineEdit | null>(null);
  // The edit in progress, read when a new one starts, so its composer is emptied exactly once.
  const editRef = useRef(edit);
  useEffect(() => {
    editRef.current = edit;
  }, [edit]);

  const startEditing = useCallback(
    (message: LocalMessage, isInThread: boolean) => {
      // The composition context is the message, so this composer edits only that message.
      const composer = new MessageComposerController({
        client,
        composition: message,
        compositionContext: message,
      });
      editRef.current?.composer.clear();
      setEdit({ composer, isInThread, messageId: message.id });
    },
    [client],
  );

  // Saving or cancelling empties the composer, which ends the edit.
  useEffect(() => {
    if (!edit) return;
    return edit.composer.state.subscribeWithSelector(
      editedMessageSelector,
      ({ editedMessage }) => {
        if (!editedMessage)
          setEdit((current) => (current === edit ? null : current));
      },
    );
  }, [edit]);

  const value = useMemo(() => ({ edit, startEditing }), [edit, startEditing]);

  return (
    <InlineEditContext.Provider value={value}>
      {children}
    </InlineEditContext.Provider>
  );
};

The edit doesn't live in the message's component. The list can remount a message, for example when the user switches channels and comes back, and the edit is still there.

The editor in place of the message

InlineEditor gives the edit composer to a MessageComposer with MessageComposerControllerProvider. Every part of that composer, the textarea, the checkmark and the Enter key among them, works on the edit composer instead of the channel's. The composer's own "Edit Message" preview is left out, because the editor already stands where the message was.

The stylesheet draws the editor as a white card with the accent border of an active field, so it stands apart from the message list and the bubbles around it. Inside the card, the composer drops the bottom bar's background, top border and padding.

A custom MessageUI renders the editor for the message being edited, and the default MessageUI for every other message:

src/InlineEditor.tsx
import type { KeyboardEvent } from "react";
import type { MessageComposer as MessageComposerController } from "stream-chat";
import {
  MessageComposer,
  MessageComposerControllerProvider,
  useMessageComposerSubmitFn,
  WithComponents,
} from "stream-chat-react";
import "./InlineEditor.css";

// The editor stands where the message was, so the composer's "Edit Message" preview of it is left
// out.
const overrides = { EditedMessagePreview: () => null };

const InlineEditorFooter = ({ onCancel }: { onCancel: () => void }) => {
  // Saves the edit, as the composer's checkmark and the Enter key do.
  const submit = useMessageComposerSubmitFn();

  return (
    <div className="cookbook-inline-editor__footer">
      <span className="cookbook-inline-editor__hint">
        Esc to cancel · Enter to save
      </span>
      <button
        className="cookbook-inline-editor__button"
        onClick={onCancel}
        type="button"
      >
        Cancel
      </button>
      <button
        className="cookbook-inline-editor__button cookbook-inline-editor__button--save"
        onClick={() => void submit()}
        type="button"
      >
        Save
      </button>
    </div>
  );
};

export const InlineEditor = ({
  composer,
}: {
  composer: MessageComposerController;
}) => {
  // Clearing the composer ends the edit and closes the editor.
  const cancel = composer.clear;

  // Runs before the textarea's own handler, so an open suggestion list or command takes Esc first.
  const handleKeyDownCapture = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key !== "Escape") return;
    const { command, suggestions } = composer.textComposer;
    if (command || suggestions) return;
    event.preventDefault();
    cancel();
  };

  return (
    <div
      className="cookbook-inline-editor"
      onKeyDownCapture={handleKeyDownCapture}
    >
      <MessageComposerControllerProvider messageComposerController={composer}>
        <WithComponents overrides={overrides}>
          <MessageComposer focus />
          <InlineEditorFooter onCancel={cancel} />
        </WithComponents>
      </MessageComposerControllerProvider>
    </div>
  );
};
src/WithInlineMessageEditing.tsx
import type { PropsWithChildren } from "react";
import {
  MessageUI,
  useMessageContext,
  useThreadContext,
  WithComponents,
} from "stream-chat-react";
import { InlineEditMessageActions } from "./InlineEditAction";
import { InlineEditor } from "./InlineEditor";
import { InlineEditProvider, useInlineEdit } from "./InlineEditContext";

const InlineEditingMessageUI = () => {
  const { message } = useMessageContext();
  const isInThread = !!useThreadContext();
  const { edit } = useInlineEdit();

  // The same message can show in the channel and in its thread; only the list it was opened in
  // shows the editor.
  const isEditedHere =
    edit?.messageId === message.id && edit.isInThread === isInThread;

  return isEditedHere ? (
    <InlineEditor composer={edit.composer} />
  ) : (
    <MessageUI />
  );
};

// Declared once, so WithComponents gets the same object on every render.
const overrides = {
  MessageActions: InlineEditMessageActions,
  MessageUI: InlineEditingMessageUI,
};

export const WithInlineMessageEditing = ({ children }: PropsWithChildren) => (
  <InlineEditProvider>
    <WithComponents overrides={overrides}>{children}</WithComponents>
  </InlineEditProvider>
);

Wrap ChatView in WithInlineMessageEditing. Above ChatView, it applies in the channel and in the thread beside it:

src/App.tsx
<Chat client={client}>
  <WithInlineMessageEditing>
    <ChatView>{/* the base app's views */}</ChatView>
  </WithInlineMessageEditing>
</Chat>

The screenshot at the top of this page shows the result.

Save, cancel and Esc

The editor ends the edit the way the composer does, by emptying the edit composer:

  • Save, the checkmark and Enter call the function from useMessageComposerSubmitFn(). While the composer holds editedMessage, it calls MessageComposer.update(), which empties the composer before the request goes out and shows the new text in the message list at once.
  • Cancel and Esc call MessageComposer.clear(). Nothing is sent.

InlineEditProvider sees editedMessage become null and drops the edit, and the message shows again in place of the editor. MessageComposer.update() never throws: a failed request leaves the new text on the message, marks it failed, and publishes an error notification. Editing explains the save.

The textarea's own key handler uses Esc to close the suggestion list or leave a command. The editor listens in the capture phase, before that handler, and leaves Esc to the textarea while composer.textComposer.suggestions or composer.textComposer.command is set.

Editing in a thread

The same components edit a reply in the thread beside the channel. InlineEditAction and InlineEditingMessageUI each read !!useThreadContext(), which is true in a thread's list. The action passes it to startEditing, and the message UI shows the editor only when it matches edit.isInThread. So the editor shows only in the list the edit was opened in, so a reply that also shows in the channel isn't edited in two places at once.

The edit composer finds its thread from the message. A reply carries parent_id, so MessageComposer.update() goes through the thread when the client holds it, and thread.messagePaginator shows the new text. What the user was typing in thread.messageComposer stays there too.

Cleaning up the edit composer

MessageComposer treats a composer supplied with MessageComposerControllerProvider as yours:

  • It registers the composer's subscriptions while it's mounted and removes them when it unmounts.
  • It doesn't save a draft of it, because MessageComposer.createDraft() skips edits.
  • It doesn't clear it on unmount. Clearing it is up to you.

InlineEditProvider does that clean-up. Starting another edit clears the previous edit composer, so two messages are never edited at once. Saving or cancelling empties the composer, the provider drops the edit, and the composer is left for garbage collection. The effect's clean-up removes the provider's subscription to the old composer.

While an edit composer is rendered, the SDK also keeps it in client.messageComposerCache under its tag, message_<message id>, which keeps the message's channel in the client. The cache holds up to 64 composers and drops the oldest when it's full. client.disconnectUser() empties it.

How it works

Info:

Example

The user picks Edit Message on their message in Design review, with "I will share the sidebar draft after lunch" in the composer at the bottom:

  1. InlineEditAction calls startEditing with the message. InlineEditProvider creates an edit composer for it and stores it with the message's id.
  2. The list re-renders the messages. InlineEditingMessageUI finds the message being edited and renders InlineEditor in its place, and the default MessageUI for the others.
  3. InlineEditor supplies the edit composer with MessageComposerControllerProvider. useMessageComposerController() picks a supplied composer before the channel's, so the textarea shows the message's text, and channel.messageComposer still holds the half-written message.
  4. The user changes the text and presses Enter. MessageComposer.update() empties the edit composer and sends the update. The message shows the new text, marked "Edited".
  5. InlineEditProvider sees that the edit composer is empty and drops the edit. The message shows again, and the composer at the bottom still holds "I will share the sidebar draft after lunch".

A message that changes on the server during an edit, for example by a new reaction, reloads the edit composer from the server's copy, and unsaved changes are lost. See Message composer.