diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 4ce9e9f8980e..c3e692b9acfa 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -15,6 +15,18 @@
       }
     ]
   },
+  "AddCaptionModal__title": {
+    "message": "Add a message",
+    "description": "Shown as the title of the dialog that allows you to add a caption to a story"
+  },
+  "AddCaptionModal__placeholder": {
+    "message": "Message",
+    "description": "Placeholder text for textarea when adding a caption/message (we don't know which yet so we default to message)"
+  },
+  "AddCaptionModal__submit-button": {
+    "message": "Done",
+    "description": "Label on the button that submits changes to a story's caption in the add-caption dialog"
+  },
   "AddUserToAnotherGroupModal__title": {
     "message": "Add to a group",
     "description": "Shown as the title of the dialog that allows you to add a contact to an group"
@@ -5335,6 +5347,10 @@
     "message": "Crop",
     "description": "Performs the crop"
   },
+  "MediaEditor__caption-button": {
+    "message": "Add a message",
+    "description": "Label of the button on the bottom of the media editor that trigger the add-caption dialog"
+  },
   "MyStories__title": {
     "message": "My Stories",
     "description": "Title for the my stories list"
diff --git a/stylesheets/components/CompositionTextArea.scss b/stylesheets/components/CompositionTextArea.scss
new file mode 100644
index 000000000000..9ea0730a4afb
--- /dev/null
+++ b/stylesheets/components/CompositionTextArea.scss
@@ -0,0 +1,65 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+.CompositionTextArea {
+  position: relative;
+
+  &__input {
+    &__input {
+      background: inherit;
+      border: none;
+      border-radius: 0;
+      height: 100%;
+
+      &:focus-within {
+        border: none;
+      }
+
+      @include dark-theme() {
+        border: none;
+
+        &:focus-within {
+          border: none;
+        }
+      }
+
+      @include keyboard-mode {
+        &:focus-within {
+          border: solid 1px $color-ultramarine;
+        }
+      }
+    }
+
+    &__input__scroller {
+      max-height: 300px;
+      min-height: 300px;
+      padding: 16px;
+      // Need more padding on the right to make room for floating emoji button
+      padding-right: 36px;
+    }
+  }
+
+  &__emoji {
+    position: absolute;
+    right: 8px;
+    top: 8px;
+
+    button::after {
+      background-color: $color-black;
+    }
+  }
+
+  &__remaining-character-count {
+    @include font-subtitle;
+    color: $color-gray-45;
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    padding: 12px 12px 12px 24px;
+  }
+
+  // remove background, should be seamless with modal
+  .module-composition-input__input {
+    background: transparent;
+  }
+}
diff --git a/stylesheets/components/ForwardMessageModal.scss b/stylesheets/components/ForwardMessageModal.scss
index 9bfeed6a1b7b..775e9f665226 100644
--- a/stylesheets/components/ForwardMessageModal.scss
+++ b/stylesheets/components/ForwardMessageModal.scss
@@ -31,41 +31,6 @@
     }
   }
 
-  &__input {
-    &__input {
-      background: inherit;
-      border: none;
-      border-radius: 0;
-      height: 100%;
-
-      &:focus-within {
-        border: none;
-      }
-
-      @include dark-theme() {
-        border: none;
-
-        &:focus-within {
-          border: none;
-        }
-      }
-
-      @include keyboard-mode {
-        &:focus-within {
-          border: solid 1px $color-ultramarine;
-        }
-      }
-    }
-
-    &__input__scroller {
-      max-height: 300px;
-      min-height: 300px;
-      padding: 16px;
-      // Need more padding on the right to make room for floating emoji button
-      padding-right: 36px;
-    }
-  }
-
   &__header {
     align-items: center;
     display: flex;
@@ -160,11 +125,6 @@
     min-height: 300px;
   }
 
-  &__text-edit-area {
-    height: 100%;
-    position: relative;
-  }
-
   &__no-candidate-contacts {
     flex-grow: 1;
     display: flex;
@@ -206,16 +166,6 @@
     }
   }
 
-  &__emoji {
-    position: absolute;
-    right: 8px;
-    top: 8px;
-
-    button::after {
-      background-color: $color-black;
-    }
-  }
-
   &__footer {
     @include font-body-2;
     align-items: center;
diff --git a/stylesheets/components/MediaEditor.scss b/stylesheets/components/MediaEditor.scss
index 65d700f20323..d0dd1a4f711c 100644
--- a/stylesheets/components/MediaEditor.scss
+++ b/stylesheets/components/MediaEditor.scss
@@ -131,6 +131,27 @@
       height: $tools-height;
       margin-bottom: 22px;
     }
+
+    &__caption {
+      height: $tools-height;
+      margin-bottom: 22px;
+
+      &__add-caption-button {
+        @include button-reset;
+        border-radius: 9999px;
+        background: $color-gray-90;
+        color: $color-gray-15;
+        padding: 8px 15px;
+        border: none;
+
+        & > span {
+          display: -webkit-box;
+          -webkit-box-orient: vertical;
+          -webkit-line-clamp: 1;
+          overflow: hidden;
+        }
+      }
+    }
   }
 
   &__controls {
diff --git a/stylesheets/components/Modal.scss b/stylesheets/components/Modal.scss
index 1b0102bdd99a..0375dd3040e3 100644
--- a/stylesheets/components/Modal.scss
+++ b/stylesheets/components/Modal.scss
@@ -133,6 +133,7 @@
     margin: 0;
     overflow-y: overlay;
     overflow-x: auto;
+    transition: border-color 150ms ease-in-out;
   }
 
   &--padded {
@@ -141,6 +142,17 @@
     }
   }
 
+  &--has-header#{&}--header-divider {
+    .module-Modal__body {
+      @include light-theme() {
+        border-top-color: $color-gray-15;
+      }
+      @include dark-theme() {
+        border-top-color: $color-gray-60;
+      }
+    }
+  }
+
   &--has-header {
     .module-Modal__body {
       padding-top: 0;
@@ -158,6 +170,23 @@
     }
   }
 
+  &--has-footer#{&}--footer-divider {
+    .module-Modal__body {
+      @include light-theme() {
+        border-bottom-color: $color-gray-15;
+      }
+      @include dark-theme() {
+        border-bottom-color: $color-gray-60;
+      }
+    }
+  }
+
+  &--has-footer {
+    .module-Modal__body {
+      border-bottom: 1px solid transparent;
+    }
+  }
+
   &__button-footer {
     display: flex;
     flex-wrap: wrap;
diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss
index 3d41641d3327..50392e5c0b09 100644
--- a/stylesheets/manifest.scss
+++ b/stylesheets/manifest.scss
@@ -50,6 +50,7 @@
 @import './components/ChatColorPicker.scss';
 @import './components/Checkbox.scss';
 @import './components/CompositionArea.scss';
+@import './components/CompositionTextArea.scss';
 @import './components/ContactModal.scss';
 @import './components/ContactName.scss';
 @import './components/ContactPill.scss';
diff --git a/ts/components/AddCaptionModal.stories.tsx b/ts/components/AddCaptionModal.stories.tsx
new file mode 100644
index 000000000000..3cc8e95cbb71
--- /dev/null
+++ b/ts/components/AddCaptionModal.stories.tsx
@@ -0,0 +1,47 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React from 'react';
+import type { Meta, Story } from '@storybook/react';
+import { action } from '@storybook/addon-actions';
+import type { Props } from './AddCaptionModal';
+import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
+import { AddCaptionModal } from './AddCaptionModal';
+import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
+import enMessages from '../../_locales/en/messages.json';
+import { setupI18n } from '../util/setupI18n';
+import { CompositionTextArea } from './CompositionTextArea';
+
+const i18n = setupI18n('en', enMessages);
+
+export default {
+  title: 'Components/AddCaptionModal',
+  component: AddCaptionModal,
+  argTypes: {
+    i18n: {
+      defaultValue: i18n,
+    },
+    RenderCompositionTextArea: {
+      defaultValue: (props: SmartCompositionTextAreaProps) => (
+        <CompositionTextArea
+          {...props}
+          i18n={i18n}
+          onPickEmoji={action('onPickEmoji')}
+          onChange={action('onChange')}
+          onTextTooLong={action('onTextTooLong')}
+          onSetSkinTone={action('onSetSkinTone')}
+          getPreferredBadge={() => undefined}
+        />
+      ),
+    },
+  },
+} as Meta;
+
+const Template: Story<Props> = args => (
+  <AddCaptionModal {...args} theme={React.useContext(StorybookThemeContext)} />
+);
+
+export const Modal = Template.bind({});
+Modal.args = {
+  draftText: 'Some caption text',
+};
diff --git a/ts/components/AddCaptionModal.tsx b/ts/components/AddCaptionModal.tsx
new file mode 100644
index 000000000000..ce8819b3bfc9
--- /dev/null
+++ b/ts/components/AddCaptionModal.tsx
@@ -0,0 +1,87 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React, { useEffect } from 'react';
+import { noop } from 'lodash';
+import { Button } from './Button';
+import { Modal } from './Modal';
+import type { LocalizerType, ThemeType } from '../types/Util';
+import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
+
+export type Props = {
+  i18n: LocalizerType;
+  onClose: () => void;
+  onSubmit: (text: string) => void;
+  draftText: string;
+  theme: ThemeType;
+  RenderCompositionTextArea: (
+    props: SmartCompositionTextAreaProps
+  ) => JSX.Element;
+};
+
+export const AddCaptionModal = ({
+  i18n,
+  onClose,
+  onSubmit,
+  draftText,
+  RenderCompositionTextArea,
+  theme,
+}: Props): JSX.Element => {
+  const [messageText, setMessageText] = React.useState('');
+
+  const [isScrolledTop, setIsScrolledTop] = React.useState(true);
+  const [isScrolledBottom, setIsScrolledBottom] = React.useState(true);
+
+  const scrollerRef = React.useRef<HTMLDivElement>(null);
+
+  // add footer/header dividers depending on the state of scroll
+  const updateScrollState = React.useCallback(() => {
+    const scrollerEl = scrollerRef.current;
+    if (scrollerEl) {
+      setIsScrolledTop(scrollerEl.scrollTop === 0);
+      setIsScrolledBottom(
+        scrollerEl.scrollHeight - scrollerEl.scrollTop ===
+          scrollerEl.clientHeight
+      );
+    }
+  }, [scrollerRef]);
+
+  useEffect(() => {
+    updateScrollState();
+  }, [updateScrollState]);
+
+  const handleSubmit = React.useCallback(() => {
+    onSubmit(messageText);
+  }, [messageText, onSubmit]);
+
+  return (
+    <Modal
+      i18n={i18n}
+      modalName="AddCaptionModal"
+      hasXButton
+      hasHeaderDivider={!isScrolledTop}
+      hasFooterDivider={!isScrolledBottom}
+      moduleClassName="AddCaptionModal"
+      padded={false}
+      title="Add a message"
+      onClose={onClose}
+      modalFooter={
+        <Button onClick={handleSubmit}>
+          {i18n('AddCaptionModal__submit-button')}
+        </Button>
+      }
+    >
+      <RenderCompositionTextArea
+        maxLength={1500}
+        whenToShowRemainingCount={1450}
+        placeholder={i18n('AddCaptionModal__placeholder')}
+        onChange={setMessageText}
+        scrollerRef={scrollerRef}
+        draftText={draftText}
+        onSubmit={noop}
+        onScroll={updateScrollState}
+        theme={theme}
+      />
+    </Modal>
+  );
+};
diff --git a/ts/components/AddUserToAnotherGroupModal.stories.tsx b/ts/components/AddUserToAnotherGroupModal.stories.tsx
index 1947406acc8e..7cf0c0887740 100644
--- a/ts/components/AddUserToAnotherGroupModal.stories.tsx
+++ b/ts/components/AddUserToAnotherGroupModal.stories.tsx
@@ -1,8 +1,8 @@
 // Copyright 2022 Signal Messenger, LLC
 // SPDX-License-Identifier: AGPL-3.0-only
 
-import type { Meta, Story } from '@storybook/react';
 import React from 'react';
+import type { Meta, Story } from '@storybook/react';
 import { action } from '@storybook/addon-actions';
 
 import type { Props } from './AddUserToAnotherGroupModal';
diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx
index dac513388695..505d6efe64dd 100644
--- a/ts/components/CompositionInput.tsx
+++ b/ts/components/CompositionInput.tsx
@@ -39,6 +39,7 @@ import { SignalClipboard } from '../quill/signal-clipboard';
 import { DirectionalBlot } from '../quill/block/blot';
 import { getClassNamesFor } from '../util/getClassNamesFor';
 import * as log from '../logging/log';
+import { useRefMerger } from '../hooks/useRefMerger';
 
 Quill.register('formats/emoji', EmojiBlot);
 Quill.register('formats/mention', MentionBlot);
@@ -55,6 +56,7 @@ type HistoryStatic = {
 export type InputApi = {
   focus: () => void;
   insertEmoji: (e: EmojiPickDataType) => void;
+  setText: (text: string, cursorToEnd?: boolean) => void;
   reset: () => void;
   resetEmojiResults: () => void;
   submit: () => void;
@@ -74,6 +76,7 @@ export type Props = {
   readonly theme: ThemeType;
   readonly placeholder?: string;
   sortedGroupMembers?: Array<ConversationType>;
+  scrollerRef?: React.RefObject<HTMLDivElement>;
   onDirtyChange?(dirty: boolean): unknown;
   onEditorStateChange?(
     messageText: string,
@@ -87,6 +90,7 @@ export type Props = {
     mentions: Array<BodyRangeType>,
     timestamp: number
   ): unknown;
+  onScroll?: (ev: React.UIEvent<HTMLElement>) => void;
   getQuotedMessage?(): unknown;
   clearQuotedMessage?(): unknown;
 };
@@ -104,6 +108,7 @@ export function CompositionInput(props: Props): React.ReactElement {
     moduleClassName,
     onPickEmoji,
     onSubmit,
+    onScroll,
     placeholder,
     skinTone,
     draftText,
@@ -115,6 +120,8 @@ export function CompositionInput(props: Props): React.ReactElement {
     theme,
   } = props;
 
+  const refMerger = useRefMerger();
+
   const [emojiCompletionElement, setEmojiCompletionElement] =
     React.useState<JSX.Element>();
   const [lastSelectionRange, setLastSelectionRange] =
@@ -125,7 +132,9 @@ export function CompositionInput(props: Props): React.ReactElement {
   const emojiCompletionRef = React.useRef<EmojiCompletion>();
   const mentionCompletionRef = React.useRef<MentionCompletion>();
   const quillRef = React.useRef<Quill>();
-  const scrollerRef = React.useRef<HTMLDivElement>(null);
+
+  const scrollerRefInner = React.useRef<HTMLDivElement>(null);
+
   const propsRef = React.useRef<Props>(props);
   const canSendRef = React.useRef<boolean>(false);
   const memberRepositoryRef = React.useRef<MemberRepository>(
@@ -219,6 +228,20 @@ export function CompositionInput(props: Props): React.ReactElement {
     historyModule.clear();
   };
 
+  const setText = (text: string, cursorToEnd?: boolean) => {
+    const quill = quillRef.current;
+
+    if (quill === undefined) {
+      return;
+    }
+
+    canSendRef.current = true;
+    quill.setText(text);
+    if (cursorToEnd) {
+      quill.setSelection(quill.getLength(), 0);
+    }
+  };
+
   const resetEmojiResults = () => {
     const emojiCompletion = emojiCompletionRef.current;
 
@@ -257,6 +280,7 @@ export function CompositionInput(props: Props): React.ReactElement {
     inputApi.current = {
       focus,
       insertEmoji,
+      setText,
       reset,
       resetEmojiResults,
       submit,
@@ -597,7 +621,7 @@ export function CompositionInput(props: Props): React.ReactElement {
               // When loading a multi-line message out of a draft, the cursor
               // position needs to be pushed to the end of the input manually.
               quill.once('editor-change', () => {
-                const scroller = scrollerRef.current;
+                const scroller = scrollerRefInner.current;
 
                 if (scroller != null) {
                   quill.scrollingContainer = scroller;
@@ -648,8 +672,13 @@ export function CompositionInput(props: Props): React.ReactElement {
         {({ ref }) => (
           <div className={getClassName('__input')} ref={ref}>
             <div
-              ref={scrollerRef}
+              ref={
+                props.scrollerRef
+                  ? refMerger(scrollerRefInner, props.scrollerRef)
+                  : scrollerRefInner
+              }
               onClick={focus}
+              onScroll={onScroll}
               className={classNames(
                 getClassName('__input__scroller'),
                 large ? getClassName('__input__scroller--large') : null,
diff --git a/ts/components/CompositionTextArea.tsx b/ts/components/CompositionTextArea.tsx
new file mode 100644
index 000000000000..ef9395f89b02
--- /dev/null
+++ b/ts/components/CompositionTextArea.tsx
@@ -0,0 +1,161 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { noop } from 'lodash';
+import React from 'react';
+import type { LocalizerType } from '../types/I18N';
+import type { EmojiPickDataType } from './emoji/EmojiPicker';
+import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
+import type { InputApi } from './CompositionInput';
+import { CompositionInput } from './CompositionInput';
+import { EmojiButton } from './emoji/EmojiButton';
+import type { BodyRangeType, ThemeType } from '../types/Util';
+import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
+import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
+import * as grapheme from '../util/grapheme';
+
+export type CompositionTextAreaProps = {
+  i18n: LocalizerType;
+  maxLength?: number;
+  placeholder?: string;
+  whenToShowRemainingCount?: number;
+  scrollerRef?: React.RefObject<HTMLDivElement>;
+  onScroll?: (ev: React.UIEvent<HTMLElement, UIEvent>) => void;
+  onPickEmoji: (e: EmojiPickDataType) => void;
+  onChange: (
+    messageText: string,
+    bodyRanges: Array<BodyRangeType>,
+    caretLocation?: number | undefined
+  ) => void;
+  onSetSkinTone: (tone: number) => void;
+  onSubmit: (
+    message: string,
+    mentions: Array<BodyRangeType>,
+    timestamp: number
+  ) => void;
+  onTextTooLong: () => void;
+  getPreferredBadge: PreferredBadgeSelectorType;
+  draftText: string;
+  theme: ThemeType;
+} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
+
+/**
+ * Essentially an HTML textarea but with support for emoji picker and
+ * at-mentions autocomplete.
+ *
+ * Meant for modals that need to collect a message or caption. It is
+ * basically a rectangle input with an emoji selector floating at the top-right
+ */
+export const CompositionTextArea = ({
+  i18n,
+  placeholder,
+  maxLength,
+  whenToShowRemainingCount = Infinity,
+  scrollerRef,
+  onScroll,
+  onPickEmoji,
+  onChange,
+  onSetSkinTone,
+  onSubmit,
+  onTextTooLong,
+  getPreferredBadge,
+  draftText,
+  theme,
+  recentEmojis,
+  skinTone,
+}: CompositionTextAreaProps): JSX.Element => {
+  const inputApiRef = React.useRef<InputApi | undefined>();
+  const [characterCount, setCharacterCount] = React.useState(
+    grapheme.count(draftText)
+  );
+
+  const insertEmoji = React.useCallback(
+    (e: EmojiPickDataType) => {
+      if (inputApiRef.current) {
+        inputApiRef.current.insertEmoji(e);
+        onPickEmoji(e);
+      }
+    },
+    [inputApiRef, onPickEmoji]
+  );
+
+  const focusTextEditInput = React.useCallback(() => {
+    if (inputApiRef.current) {
+      inputApiRef.current.focus();
+    }
+  }, [inputApiRef]);
+
+  const handleChange = React.useCallback(
+    (
+      newValue: string,
+      bodyRanges: Array<BodyRangeType>,
+      caretLocation?: number | undefined
+    ) => {
+      const inputEl = inputApiRef.current;
+      if (!inputEl) {
+        return;
+      }
+
+      const [newValueSized, newCharacterCount] = grapheme.truncateAndSize(
+        newValue,
+        maxLength
+      );
+
+      if (maxLength !== undefined) {
+        // if we had to truncate
+        if (newValueSized.length < newValue.length) {
+          // reset quill to the value before the change that pushed it over the max
+          // and push the cursor to the end
+          //
+          // this is not perfect as it pushes the cursor to the end, even if the user
+          // was modifying text in the middle of the editor
+          // a better solution would be to prevent the change to begin with, but
+          // quill makes this VERY difficult
+          inputEl.setText(newValueSized, true);
+        }
+      }
+      setCharacterCount(newCharacterCount);
+      onChange(newValue, bodyRanges, caretLocation);
+    },
+    [maxLength, onChange]
+  );
+
+  return (
+    <div className="CompositionTextArea">
+      <CompositionInput
+        placeholder={placeholder}
+        clearQuotedMessage={shouldNeverBeCalled}
+        scrollerRef={scrollerRef}
+        getPreferredBadge={getPreferredBadge}
+        getQuotedMessage={noop}
+        i18n={i18n}
+        inputApi={inputApiRef}
+        large
+        moduleClassName="CompositionTextArea__input"
+        onScroll={onScroll}
+        onEditorStateChange={handleChange}
+        onPickEmoji={onPickEmoji}
+        onSubmit={onSubmit}
+        onTextTooLong={onTextTooLong}
+        draftText={draftText}
+        theme={theme}
+      />
+      <div className="CompositionTextArea__emoji">
+        <EmojiButton
+          i18n={i18n}
+          onClose={focusTextEditInput}
+          onPickEmoji={insertEmoji}
+          onSetSkinTone={onSetSkinTone}
+          recentEmojis={recentEmojis}
+          skinTone={skinTone}
+        />
+      </div>
+      {maxLength !== undefined &&
+        characterCount >= whenToShowRemainingCount && (
+          <div className="CompositionTextArea__remaining-character-count">
+            {maxLength - characterCount}
+          </div>
+        )}
+    </div>
+  );
+};
diff --git a/ts/components/ForwardMessageModal.stories.tsx b/ts/components/ForwardMessageModal.stories.tsx
index 85dbadd20bf7..959669ca8af0 100644
--- a/ts/components/ForwardMessageModal.stories.tsx
+++ b/ts/components/ForwardMessageModal.stories.tsx
@@ -14,6 +14,7 @@ import { IMAGE_JPEG, VIDEO_MP4, stringToMIMEType } from '../types/MIME';
 import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
 import { setupI18n } from '../util/setupI18n';
 import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
+import { CompositionTextArea } from './CompositionTextArea';
 
 const createAttachment = (
   props: Partial<AttachmentType> = {}
@@ -55,12 +56,18 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
   messageBody: text('messageBody', overrideProps.messageBody || ''),
   onClose: action('onClose'),
   onEditorStateChange: action('onEditorStateChange'),
-  onPickEmoji: action('onPickEmoji'),
-  onTextTooLong: action('onTextTooLong'),
-  onSetSkinTone: action('onSetSkinTone'),
-  recentEmojis: [],
   removeLinkPreview: action('removeLinkPreview'),
-  skinTone: 0,
+  RenderCompositionTextArea: props => (
+    <CompositionTextArea
+      {...props}
+      i18n={i18n}
+      onPickEmoji={action('onPickEmoji')}
+      skinTone={0}
+      onSetSkinTone={action('onSetSkinTone')}
+      onTextTooLong={action('onTextTooLong')}
+      getPreferredBadge={() => undefined}
+    />
+  ),
   theme: React.useContext(StorybookThemeContext),
   regionCode: 'US',
 });
diff --git a/ts/components/ForwardMessageModal.tsx b/ts/components/ForwardMessageModal.tsx
index 62da3055ef4c..64c97eebaeaf 100644
--- a/ts/components/ForwardMessageModal.tsx
+++ b/ts/components/ForwardMessageModal.tsx
@@ -11,26 +11,21 @@ import React, {
 } from 'react';
 import type { MeasuredComponentProps } from 'react-measure';
 import Measure from 'react-measure';
-import { noop } from 'lodash';
 import { animated } from '@react-spring/web';
 
 import classNames from 'classnames';
 import { AttachmentList } from './conversation/AttachmentList';
 import type { AttachmentType } from '../types/Attachment';
 import { Button } from './Button';
-import type { InputApi } from './CompositionInput';
-import { CompositionInput } from './CompositionInput';
 import { ConfirmationDialog } from './ConfirmationDialog';
 import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
 import type { Row } from './ConversationList';
 import { ConversationList, RowType } from './ConversationList';
 import type { ConversationType } from '../state/ducks/conversations';
 import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
-import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
-import { EmojiButton } from './emoji/EmojiButton';
-import type { EmojiPickDataType } from './emoji/EmojiPicker';
 import type { LinkPreviewType } from '../types/message/LinkPreviews';
 import type { BodyRangeType, LocalizerType, ThemeType } from '../types/Util';
+import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
 import { ModalHost } from './ModalHost';
 import { SearchInput } from './SearchInput';
 import { StagedLinkPreview } from './conversation/StagedLinkPreview';
@@ -62,15 +57,14 @@ export type DataPropsType = {
     bodyRanges: Array<BodyRangeType>,
     caretLocation?: number
   ) => unknown;
-  onTextTooLong: () => void;
   theme: ThemeType;
   regionCode: string | undefined;
-} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
+  RenderCompositionTextArea: (
+    props: SmartCompositionTextAreaProps
+  ) => JSX.Element;
+};
 
-type ActionPropsType = Pick<
-  EmojiButtonProps,
-  'onPickEmoji' | 'onSetSkinTone'
-> & {
+type ActionPropsType = {
   removeLinkPreview: () => void;
 };
 
@@ -90,17 +84,12 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
   messageBody,
   onClose,
   onEditorStateChange,
-  onPickEmoji,
-  onSetSkinTone,
-  onTextTooLong,
-  recentEmojis,
   removeLinkPreview,
-  skinTone,
+  RenderCompositionTextArea,
   theme,
   regionCode,
 }) => {
   const inputRef = useRef<null | HTMLInputElement>(null);
-  const inputApiRef = React.useRef<InputApi | undefined>();
   const [selectedContacts, setSelectedContacts] = useState<
     Array<ConversationType>
   >([]);
@@ -125,22 +114,6 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
     [selectedContacts]
   );
 
-  const focusTextEditInput = React.useCallback(() => {
-    if (inputApiRef.current) {
-      inputApiRef.current.focus();
-    }
-  }, [inputApiRef]);
-
-  const insertEmoji = React.useCallback(
-    (e: EmojiPickDataType) => {
-      if (inputApiRef.current) {
-        inputApiRef.current.insertEmoji(e);
-        onPickEmoji(e);
-      }
-    },
-    [inputApiRef, onPickEmoji]
-  );
-
   const hasContactsSelected = Boolean(selectedContacts.length);
 
   const canForwardMessage =
@@ -351,40 +324,16 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
                   }}
                 />
               ) : null}
-              <div className="module-ForwardMessageModal__text-edit-area">
-                <CompositionInput
-                  clearQuotedMessage={shouldNeverBeCalled}
-                  draftText={messageBodyText}
-                  getPreferredBadge={getPreferredBadge}
-                  getQuotedMessage={noop}
-                  i18n={i18n}
-                  inputApi={inputApiRef}
-                  large
-                  moduleClassName="module-ForwardMessageModal__input"
-                  onEditorStateChange={(
-                    messageText,
-                    bodyRanges,
-                    caretLocation
-                  ) => {
-                    setMessageBodyText(messageText);
-                    onEditorStateChange(messageText, bodyRanges, caretLocation);
-                  }}
-                  onPickEmoji={onPickEmoji}
-                  onSubmit={forwardMessage}
-                  onTextTooLong={onTextTooLong}
-                  theme={theme}
-                />
-                <div className="module-ForwardMessageModal__emoji">
-                  <EmojiButton
-                    i18n={i18n}
-                    onClose={focusTextEditInput}
-                    onPickEmoji={insertEmoji}
-                    onSetSkinTone={onSetSkinTone}
-                    recentEmojis={recentEmojis}
-                    skinTone={skinTone}
-                  />
-                </div>
-              </div>
+
+              <RenderCompositionTextArea
+                draftText={messageBodyText}
+                onChange={(messageText, bodyRanges, caretLocation?) => {
+                  setMessageBodyText(messageText);
+                  onEditorStateChange(messageText, bodyRanges, caretLocation);
+                }}
+                onSubmit={forwardMessage}
+                theme={theme}
+              />
             </div>
           ) : (
             <div className="module-ForwardMessageModal__main-body">
diff --git a/ts/components/MediaEditor.stories.tsx b/ts/components/MediaEditor.stories.tsx
index e10fdd1ff2be..f849b4ed069d 100644
--- a/ts/components/MediaEditor.stories.tsx
+++ b/ts/components/MediaEditor.stories.tsx
@@ -9,6 +9,7 @@ import { MediaEditor } from './MediaEditor';
 import enMessages from '../../_locales/en/messages.json';
 import { setupI18n } from '../util/setupI18n';
 import { Stickers, installedPacks } from '../test-both/helpers/getStickerPacks';
+import { CompositionTextArea } from './CompositionTextArea';
 
 const i18n = setupI18n('en', enMessages);
 
@@ -47,3 +48,20 @@ export const Smol = (): JSX.Element => (
 export const Portrait = (): JSX.Element => (
   <MediaEditor {...getDefaultProps()} imageSrc={IMAGE_4} />
 );
+
+export const WithCaption = (): JSX.Element => (
+  <MediaEditor
+    {...getDefaultProps()}
+    supportsCaption
+    renderCompositionTextArea={props => (
+      <CompositionTextArea
+        {...props}
+        i18n={i18n}
+        onPickEmoji={action('onPickEmoji')}
+        onSetSkinTone={action('onSetSkinTone')}
+        onTextTooLong={action('onTextTooLong')}
+        getPreferredBadge={() => undefined}
+      />
+    )}
+  />
+);
diff --git a/ts/components/MediaEditor.tsx b/ts/components/MediaEditor.tsx
index 45df98f082d8..f91f86528155 100644
--- a/ts/components/MediaEditor.tsx
+++ b/ts/components/MediaEditor.tsx
@@ -9,6 +9,7 @@ import { fabric } from 'fabric';
 import { get, has, noop } from 'lodash';
 
 import type { LocalizerType } from '../types/Util';
+import { ThemeType } from '../types/Util';
 import type { Props as StickerButtonProps } from './stickers/StickerButton';
 import type { ImageStateType } from '../mediaEditor/ImageStateType';
 
@@ -33,14 +34,30 @@ import {
   TextStyle,
   getTextStyleAttributes,
 } from '../mediaEditor/util/getTextStyleAttributes';
+import { AddCaptionModal } from './AddCaptionModal';
+import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
+import { Emojify } from './conversation/Emojify';
+import { AddNewLines } from './conversation/AddNewLines';
 
 export type PropsType = {
   doneButtonLabel?: string;
   i18n: LocalizerType;
   imageSrc: string;
   onClose: () => unknown;
-  onDone: (data: Uint8Array) => unknown;
-} & Pick<StickerButtonProps, 'installedPacks' | 'recentStickers'>;
+  onDone: (data: Uint8Array, caption?: string | undefined) => unknown;
+} & Pick<StickerButtonProps, 'installedPacks' | 'recentStickers'> &
+  (
+    | {
+        supportsCaption: true;
+        renderCompositionTextArea: (
+          props: SmartCompositionTextAreaProps
+        ) => JSX.Element;
+      }
+    | {
+        supportsCaption?: false;
+        renderCompositionTextArea?: undefined;
+      }
+  );
 
 const INITIAL_IMAGE_STATE: ImageStateType = {
   angle: 0,
@@ -94,12 +111,17 @@ export const MediaEditor = ({
   // StickerButtonProps
   installedPacks,
   recentStickers,
+  ...props
 }: PropsType): JSX.Element | null => {
   const [fabricCanvas, setFabricCanvas] = useState<fabric.Canvas | undefined>();
   const [image, setImage] = useState<HTMLImageElement>(new Image());
   const [isStickerPopperOpen, setIsStickerPopperOpen] =
     useState<boolean>(false);
 
+  const [caption, setCaption] = useState('');
+
+  const [showAddCaptionModal, setShowAddCaptionModal] = useState(false);
+
   const canvasId = useUniqueId();
 
   const [imageState, setImageState] =
@@ -892,7 +914,46 @@ export const MediaEditor = ({
         {tooling ? (
           <div className="MediaEditor__tools">{tooling}</div>
         ) : (
-          <div className="MediaEditor__toolbar--space" />
+          <>
+            {props.supportsCaption ? (
+              <div className="MediaEditor__toolbar__caption">
+                <button
+                  type="button"
+                  className="MediaEditor__toolbar__caption__add-caption-button"
+                  onClick={() => setShowAddCaptionModal(true)}
+                >
+                  {caption !== '' ? (
+                    <span>
+                      <AddNewLines
+                        text={caption}
+                        renderNonNewLine={({ key, text }) => (
+                          <Emojify key={key} text={text} />
+                        )}
+                      />
+                    </span>
+                  ) : (
+                    i18n('MediaEditor__caption-button')
+                  )}
+                </button>
+
+                {showAddCaptionModal && (
+                  <AddCaptionModal
+                    i18n={i18n}
+                    draftText={caption}
+                    onSubmit={messageText => {
+                      setCaption(messageText.trim());
+                      setShowAddCaptionModal(false);
+                    }}
+                    onClose={() => setShowAddCaptionModal(false)}
+                    RenderCompositionTextArea={props.renderCompositionTextArea}
+                    theme={ThemeType.dark}
+                  />
+                )}
+              </div>
+            ) : (
+              <div className="MediaEditor__toolbar--space" />
+            )}
+          </>
         )}
         <div className="MediaEditor__toolbar--buttons">
           <Button
@@ -1087,7 +1148,7 @@ export const MediaEditor = ({
                 setIsSaving(false);
               }
 
-              onDone(data);
+              onDone(data, caption !== '' ? caption : undefined);
             }}
             theme={Theme.Dark}
             variant={ButtonVariant.Primary}
diff --git a/ts/components/Modal.tsx b/ts/components/Modal.tsx
index cad8bd77d53c..503ed0a2c6db 100644
--- a/ts/components/Modal.tsx
+++ b/ts/components/Modal.tsx
@@ -21,6 +21,8 @@ type PropsType = {
   children: ReactNode;
   modalName: string;
   hasXButton?: boolean;
+  hasHeaderDivider?: boolean;
+  hasFooterDivider?: boolean;
   i18n: LocalizerType;
   modalFooter?: JSX.Element;
   moduleClassName?: string;
@@ -51,6 +53,8 @@ export function Modal({
   theme,
   title,
   useFocusTrap,
+  hasHeaderDivider = false,
+  hasFooterDivider = false,
   padded = true,
 }: Readonly<ModalPropsType>): ReactElement {
   const { close, modalStyles, overlayStyles } = useAnimated(onClose, {
@@ -82,6 +86,8 @@ export function Modal({
           onClose={close}
           title={title}
           padded={padded}
+          hasHeaderDivider={hasHeaderDivider}
+          hasFooterDivider={hasFooterDivider}
         >
           {children}
         </ModalPage>
@@ -120,6 +126,8 @@ export function ModalPage({
   onClose,
   title,
   padded = true,
+  hasHeaderDivider = false,
+  hasFooterDivider = false,
 }: ModalPageProps): JSX.Element {
   const modalRef = useRef<HTMLDivElement | null>(null);
 
@@ -151,7 +159,10 @@ export function ModalPage({
         className={classNames(
           getClassName(''),
           getClassName(hasHeader ? '--has-header' : '--no-header'),
-          padded && getClassName('--padded')
+          Boolean(modalFooter) && getClassName('--has-footer'),
+          padded && getClassName('--padded'),
+          hasHeaderDivider && getClassName('--header-divider'),
+          hasFooterDivider && getClassName('--footer-divider')
         )}
         ref={modalRef}
         onClick={event => {
diff --git a/ts/components/StoryCreator.tsx b/ts/components/StoryCreator.tsx
index 3c32ebbb4422..ce9ce2335afc 100644
--- a/ts/components/StoryCreator.tsx
+++ b/ts/components/StoryCreator.tsx
@@ -21,6 +21,7 @@ import { SendStoryModal } from './SendStoryModal';
 
 import { MediaEditor } from './MediaEditor';
 import { TextStoryCreator } from './TextStoryCreator';
+import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
 
 export type PropsType = {
   debouncedMaybeGrabLinkPreview: (
@@ -39,6 +40,9 @@ export type PropsType = {
   processAttachment: (
     file: File
   ) => Promise<void | InMemoryAttachmentDraftType>;
+  renderCompositionTextArea: (
+    props: SmartCompositionTextAreaProps
+  ) => JSX.Element;
   sendStoryModalOpenStateChanged: (isOpen: boolean) => unknown;
 } & Pick<StickerButtonProps, 'installedPacks' | 'recentStickers'> &
   Pick<
@@ -87,6 +91,7 @@ export const StoryCreator = ({
   onViewersUpdated,
   processAttachment,
   recentStickers,
+  renderCompositionTextArea,
   sendStoryModalOpenStateChanged,
   setMyStoriesToAllSignalConnections,
   signalConnections,
@@ -174,11 +179,14 @@ export const StoryCreator = ({
           imageSrc={attachmentUrl}
           installedPacks={installedPacks}
           onClose={onClose}
-          onDone={data => {
+          supportsCaption
+          renderCompositionTextArea={renderCompositionTextArea}
+          onDone={(data, caption) => {
             setDraftAttachment({
               contentType: IMAGE_JPEG,
               data,
               size: data.byteLength,
+              caption,
             });
           }}
           recentStickers={recentStickers}
diff --git a/ts/state/smart/CompositionTextArea.tsx b/ts/state/smart/CompositionTextArea.tsx
new file mode 100644
index 000000000000..931cd652a9e0
--- /dev/null
+++ b/ts/state/smart/CompositionTextArea.tsx
@@ -0,0 +1,50 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React from 'react';
+import { useSelector } from 'react-redux';
+import type { CompositionTextAreaProps } from '../../components/CompositionTextArea';
+import { CompositionTextArea } from '../../components/CompositionTextArea';
+import type { LocalizerType } from '../../types/I18N';
+import type { StateType } from '../reducer';
+import { getIntl } from '../selectors/user';
+import { useActions as useEmojiActions } from '../ducks/emojis';
+import { useActions as useItemsActions } from '../ducks/items';
+import { getPreferredBadgeSelector } from '../selectors/badges';
+import { showToast } from '../../util/showToast';
+import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong';
+
+export type SmartCompositionTextAreaProps = Pick<
+  CompositionTextAreaProps,
+  | 'draftText'
+  | 'placeholder'
+  | 'onChange'
+  | 'onScroll'
+  | 'onSubmit'
+  | 'theme'
+  | 'maxLength'
+  | 'whenToShowRemainingCount'
+  | 'scrollerRef'
+>;
+
+export const SmartCompositionTextArea = (
+  props: SmartCompositionTextAreaProps
+): JSX.Element => {
+  const i18n = useSelector<StateType, LocalizerType>(getIntl);
+
+  const { onUseEmoji: onPickEmoji } = useEmojiActions();
+  const { onSetSkinTone } = useItemsActions();
+
+  const getPreferredBadge = useSelector(getPreferredBadgeSelector);
+
+  return (
+    <CompositionTextArea
+      {...props}
+      i18n={i18n}
+      onPickEmoji={onPickEmoji}
+      onSetSkinTone={onSetSkinTone}
+      getPreferredBadge={getPreferredBadge}
+      onTextTooLong={() => showToast(ToastMessageBodyTooLong)}
+    />
+  );
+};
diff --git a/ts/state/smart/ForwardMessageModal.tsx b/ts/state/smart/ForwardMessageModal.tsx
index 2ed0ad744bfe..ef3f0b7c4215 100644
--- a/ts/state/smart/ForwardMessageModal.tsx
+++ b/ts/state/smart/ForwardMessageModal.tsx
@@ -9,13 +9,11 @@ import type { StateType } from '../reducer';
 import * as log from '../../logging/log';
 import { ForwardMessageModal } from '../../components/ForwardMessageModal';
 import { LinkPreviewSourceType } from '../../types/LinkPreview';
-import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong';
 import type { GetConversationByIdType } from '../selectors/conversations';
 import {
   getAllComposableConversations,
   getConversationSelector,
 } from '../selectors/conversations';
-import { getEmojiSkinTone } from '../selectors/items';
 import { getIntl, getTheme, getRegionCode } from '../selectors/user';
 import { getLinkPreview } from '../selectors/linkPreviews';
 import { getMessageById } from '../../messages/getMessageById';
@@ -25,14 +23,11 @@ import {
   maybeGrabLinkPreview,
   resetLinkPreview,
 } from '../../services/LinkPreview';
-import { selectRecentEmojis } from '../selectors/emojis';
-import { showToast } from '../../util/showToast';
-import { useActions as useEmojiActions } from '../ducks/emojis';
-import { useActions as useItemsActions } from '../ducks/items';
 import { useGlobalModalActions } from '../ducks/globalModals';
 import { useLinkPreviewActions } from '../ducks/linkPreviews';
 import { processBodyRanges } from '../selectors/message';
 import { getTextWithMentions } from '../../util/getTextWithMentions';
+import { SmartCompositionTextArea } from './CompositionTextArea';
 
 function renderMentions(
   message: ForwardMessagePropsType,
@@ -65,14 +60,10 @@ export function SmartForwardMessageModal(): JSX.Element | null {
   const getConversation = useSelector(getConversationSelector);
   const i18n = useSelector(getIntl);
   const linkPreviewForSource = useSelector(getLinkPreview);
-  const recentEmojis = useSelector(selectRecentEmojis);
   const regionCode = useSelector(getRegionCode);
-  const skinTone = useSelector(getEmojiSkinTone);
   const theme = useSelector(getTheme);
 
   const { removeLinkPreview } = useLinkPreviewActions();
-  const { onUseEmoji: onPickEmoji } = useEmojiActions();
-  const { onSetSkinTone } = useItemsActions();
   const { toggleForwardMessageModal } = useGlobalModalActions();
 
   if (!forwardMessageProps) {
@@ -141,13 +132,9 @@ export function SmartForwardMessageModal(): JSX.Element | null {
           );
         }
       }}
-      onPickEmoji={onPickEmoji}
-      onSetSkinTone={onSetSkinTone}
-      onTextTooLong={() => showToast(ToastMessageBodyTooLong)}
-      recentEmojis={recentEmojis}
       regionCode={regionCode}
+      RenderCompositionTextArea={SmartCompositionTextArea}
       removeLinkPreview={removeLinkPreview}
-      skinTone={skinTone}
       theme={theme}
     />
   );
diff --git a/ts/state/smart/StoryCreator.tsx b/ts/state/smart/StoryCreator.tsx
index 369f38d5561d..e090476716b8 100644
--- a/ts/state/smart/StoryCreator.tsx
+++ b/ts/state/smart/StoryCreator.tsx
@@ -30,6 +30,7 @@ import { useGlobalModalActions } from '../ducks/globalModals';
 import { useLinkPreviewActions } from '../ducks/linkPreviews';
 import { useStoriesActions } from '../ducks/stories';
 import { useStoryDistributionListsActions } from '../ducks/storyDistributionLists';
+import { SmartCompositionTextArea } from './CompositionTextArea';
 
 export type PropsType = {
   file?: File;
@@ -96,6 +97,7 @@ export function SmartStoryCreator({
       onViewersUpdated={updateStoryViewers}
       processAttachment={processAttachment}
       recentStickers={recentStickers}
+      renderCompositionTextArea={SmartCompositionTextArea}
       sendStoryModalOpenStateChanged={sendStoryModalOpenStateChanged}
       setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections}
       signalConnections={signalConnections}
diff --git a/ts/util/grapheme.ts b/ts/util/grapheme.ts
index 1e192030b081..ba483e66a76a 100644
--- a/ts/util/grapheme.ts
+++ b/ts/util/grapheme.ts
@@ -1,7 +1,7 @@
 // Copyright 2021-2022 Signal Messenger, LLC
 // SPDX-License-Identifier: AGPL-3.0-only
 
-import { map, size } from './iterables';
+import { map, size, take, join } from './iterables';
 
 export function getGraphemes(str: string): Iterable<string> {
   const segments = new Intl.Segmenter().segment(str);
@@ -13,6 +13,25 @@ export function count(str: string): number {
   return size(segments);
 }
 
+/** @return truncated string and size (after any truncation) */
+export function truncateAndSize(
+  str: string,
+  toSize?: number
+): [string, number] {
+  const segments = new Intl.Segmenter().segment(str);
+  const originalSize = size(segments);
+  if (toSize === undefined || originalSize <= toSize) {
+    return [str, originalSize];
+  }
+  return [
+    join(
+      map(take(segments, toSize), s => s.segment),
+      ''
+    ),
+    toSize,
+  ];
+}
+
 export function isSingleGrapheme(str: string): boolean {
   if (str === '') {
     return false;
diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json
index a11fb166d4ce..eb2b8abebb72 100644
--- a/ts/util/lint/exceptions.json
+++ b/ts/util/lint/exceptions.json
@@ -15,6 +15,27 @@
     "updated": "2018-09-18T19:19:27.699Z",
     "reasonDetail": "Part of runtime library for C++ transpiled code"
   },
+  {
+    "rule": "React-useRef",
+    "path": "ts/components/AddCaptionModal.tsx",
+    "line": "  const scrollerRef = React.useRef<HTMLDivElement>(null);",
+    "reasonCategory": "usageTrusted",
+    "updated": "2022-10-03T16:06:12.837Z"
+  },
+  {
+    "rule": "React-useRef",
+    "path": "ts/components/CompositionInput.tsx",
+    "line": "  const scrollerRefInner = React.useRef<HTMLDivElement>(null);",
+    "reasonCategory": "usageTrusted",
+    "updated": "2022-10-03T16:06:12.837Z"
+  },
+  {
+    "rule": "React-useRef",
+    "path": "ts/components/CompositionTextArea.tsx",
+    "line": "  const inputApiRef = React.useRef<InputApi | undefined>();",
+    "reasonCategory": "usageTrusted",
+    "updated": "2022-10-03T16:06:12.837Z"
+  },
   {
     "rule": "jQuery-append(",
     "path": "components/mp3lameencoder/lib/Mp3LameEncoder.js",
@@ -8986,13 +9007,6 @@
     "reasonCategory": "usageTrusted",
     "updated": "2021-07-30T16:57:33.618Z"
   },
-  {
-    "rule": "React-useRef",
-    "path": "ts/components/CompositionInput.tsx",
-    "line": "  const scrollerRef = React.useRef<HTMLDivElement>(null);",
-    "reasonCategory": "usageTrusted",
-    "updated": "2021-07-30T16:57:33.618Z"
-  },
   {
     "rule": "React-useRef",
     "path": "ts/components/CompositionInput.tsx",
@@ -9050,13 +9064,6 @@
     "reasonCategory": "usageTrusted",
     "updated": "2021-07-30T16:57:33.618Z"
   },
-  {
-    "rule": "React-useRef",
-    "path": "ts/components/ForwardMessageModal.tsx",
-    "line": "  const inputApiRef = React.useRef<InputApi | undefined>();",
-    "reasonCategory": "usageTrusted",
-    "updated": "2021-07-30T16:57:33.618Z"
-  },
   {
     "rule": "React-useRef",
     "path": "ts/components/GradientDial.tsx",