Story - add caption

This commit is contained in:
Alvaro 2022-10-04 17:17:15 -06:00 committed by GitHub
parent 8fcd36e30a
commit c52fe3f377
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 688 additions and 163 deletions

View File

@ -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": { "AddUserToAnotherGroupModal__title": {
"message": "Add to a group", "message": "Add to a group",
"description": "Shown as the title of the dialog that allows you to add a contact to an 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", "message": "Crop",
"description": "Performs the 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": { "MyStories__title": {
"message": "My Stories", "message": "My Stories",
"description": "Title for the my stories list" "description": "Title for the my stories list"

View File

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

View File

@ -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 { &__header {
align-items: center; align-items: center;
display: flex; display: flex;
@ -160,11 +125,6 @@
min-height: 300px; min-height: 300px;
} }
&__text-edit-area {
height: 100%;
position: relative;
}
&__no-candidate-contacts { &__no-candidate-contacts {
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
@ -206,16 +166,6 @@
} }
} }
&__emoji {
position: absolute;
right: 8px;
top: 8px;
button::after {
background-color: $color-black;
}
}
&__footer { &__footer {
@include font-body-2; @include font-body-2;
align-items: center; align-items: center;

View File

@ -131,6 +131,27 @@
height: $tools-height; height: $tools-height;
margin-bottom: 22px; 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 { &__controls {

View File

@ -133,6 +133,7 @@
margin: 0; margin: 0;
overflow-y: overlay; overflow-y: overlay;
overflow-x: auto; overflow-x: auto;
transition: border-color 150ms ease-in-out;
} }
&--padded { &--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 { &--has-header {
.module-Modal__body { .module-Modal__body {
padding-top: 0; 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 { &__button-footer {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@ -50,6 +50,7 @@
@import './components/ChatColorPicker.scss'; @import './components/ChatColorPicker.scss';
@import './components/Checkbox.scss'; @import './components/Checkbox.scss';
@import './components/CompositionArea.scss'; @import './components/CompositionArea.scss';
@import './components/CompositionTextArea.scss';
@import './components/ContactModal.scss'; @import './components/ContactModal.scss';
@import './components/ContactName.scss'; @import './components/ContactName.scss';
@import './components/ContactPill.scss'; @import './components/ContactPill.scss';

View File

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

View File

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

View File

@ -1,8 +1,8 @@
// Copyright 2022 Signal Messenger, LLC // Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import React from 'react'; import React from 'react';
import type { Meta, Story } from '@storybook/react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import type { Props } from './AddUserToAnotherGroupModal'; import type { Props } from './AddUserToAnotherGroupModal';

View File

@ -39,6 +39,7 @@ import { SignalClipboard } from '../quill/signal-clipboard';
import { DirectionalBlot } from '../quill/block/blot'; import { DirectionalBlot } from '../quill/block/blot';
import { getClassNamesFor } from '../util/getClassNamesFor'; import { getClassNamesFor } from '../util/getClassNamesFor';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { useRefMerger } from '../hooks/useRefMerger';
Quill.register('formats/emoji', EmojiBlot); Quill.register('formats/emoji', EmojiBlot);
Quill.register('formats/mention', MentionBlot); Quill.register('formats/mention', MentionBlot);
@ -55,6 +56,7 @@ type HistoryStatic = {
export type InputApi = { export type InputApi = {
focus: () => void; focus: () => void;
insertEmoji: (e: EmojiPickDataType) => void; insertEmoji: (e: EmojiPickDataType) => void;
setText: (text: string, cursorToEnd?: boolean) => void;
reset: () => void; reset: () => void;
resetEmojiResults: () => void; resetEmojiResults: () => void;
submit: () => void; submit: () => void;
@ -74,6 +76,7 @@ export type Props = {
readonly theme: ThemeType; readonly theme: ThemeType;
readonly placeholder?: string; readonly placeholder?: string;
sortedGroupMembers?: Array<ConversationType>; sortedGroupMembers?: Array<ConversationType>;
scrollerRef?: React.RefObject<HTMLDivElement>;
onDirtyChange?(dirty: boolean): unknown; onDirtyChange?(dirty: boolean): unknown;
onEditorStateChange?( onEditorStateChange?(
messageText: string, messageText: string,
@ -87,6 +90,7 @@ export type Props = {
mentions: Array<BodyRangeType>, mentions: Array<BodyRangeType>,
timestamp: number timestamp: number
): unknown; ): unknown;
onScroll?: (ev: React.UIEvent<HTMLElement>) => void;
getQuotedMessage?(): unknown; getQuotedMessage?(): unknown;
clearQuotedMessage?(): unknown; clearQuotedMessage?(): unknown;
}; };
@ -104,6 +108,7 @@ export function CompositionInput(props: Props): React.ReactElement {
moduleClassName, moduleClassName,
onPickEmoji, onPickEmoji,
onSubmit, onSubmit,
onScroll,
placeholder, placeholder,
skinTone, skinTone,
draftText, draftText,
@ -115,6 +120,8 @@ export function CompositionInput(props: Props): React.ReactElement {
theme, theme,
} = props; } = props;
const refMerger = useRefMerger();
const [emojiCompletionElement, setEmojiCompletionElement] = const [emojiCompletionElement, setEmojiCompletionElement] =
React.useState<JSX.Element>(); React.useState<JSX.Element>();
const [lastSelectionRange, setLastSelectionRange] = const [lastSelectionRange, setLastSelectionRange] =
@ -125,7 +132,9 @@ export function CompositionInput(props: Props): React.ReactElement {
const emojiCompletionRef = React.useRef<EmojiCompletion>(); const emojiCompletionRef = React.useRef<EmojiCompletion>();
const mentionCompletionRef = React.useRef<MentionCompletion>(); const mentionCompletionRef = React.useRef<MentionCompletion>();
const quillRef = React.useRef<Quill>(); const quillRef = React.useRef<Quill>();
const scrollerRef = React.useRef<HTMLDivElement>(null);
const scrollerRefInner = React.useRef<HTMLDivElement>(null);
const propsRef = React.useRef<Props>(props); const propsRef = React.useRef<Props>(props);
const canSendRef = React.useRef<boolean>(false); const canSendRef = React.useRef<boolean>(false);
const memberRepositoryRef = React.useRef<MemberRepository>( const memberRepositoryRef = React.useRef<MemberRepository>(
@ -219,6 +228,20 @@ export function CompositionInput(props: Props): React.ReactElement {
historyModule.clear(); 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 resetEmojiResults = () => {
const emojiCompletion = emojiCompletionRef.current; const emojiCompletion = emojiCompletionRef.current;
@ -257,6 +280,7 @@ export function CompositionInput(props: Props): React.ReactElement {
inputApi.current = { inputApi.current = {
focus, focus,
insertEmoji, insertEmoji,
setText,
reset, reset,
resetEmojiResults, resetEmojiResults,
submit, submit,
@ -597,7 +621,7 @@ export function CompositionInput(props: Props): React.ReactElement {
// When loading a multi-line message out of a draft, the cursor // When loading a multi-line message out of a draft, the cursor
// position needs to be pushed to the end of the input manually. // position needs to be pushed to the end of the input manually.
quill.once('editor-change', () => { quill.once('editor-change', () => {
const scroller = scrollerRef.current; const scroller = scrollerRefInner.current;
if (scroller != null) { if (scroller != null) {
quill.scrollingContainer = scroller; quill.scrollingContainer = scroller;
@ -648,8 +672,13 @@ export function CompositionInput(props: Props): React.ReactElement {
{({ ref }) => ( {({ ref }) => (
<div className={getClassName('__input')} ref={ref}> <div className={getClassName('__input')} ref={ref}>
<div <div
ref={scrollerRef} ref={
props.scrollerRef
? refMerger(scrollerRefInner, props.scrollerRef)
: scrollerRefInner
}
onClick={focus} onClick={focus}
onScroll={onScroll}
className={classNames( className={classNames(
getClassName('__input__scroller'), getClassName('__input__scroller'),
large ? getClassName('__input__scroller--large') : null, large ? getClassName('__input__scroller--large') : null,

View File

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

View File

@ -14,6 +14,7 @@ import { IMAGE_JPEG, VIDEO_MP4, stringToMIMEType } from '../types/MIME';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n'; import { setupI18n } from '../util/setupI18n';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext'; import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
import { CompositionTextArea } from './CompositionTextArea';
const createAttachment = ( const createAttachment = (
props: Partial<AttachmentType> = {} props: Partial<AttachmentType> = {}
@ -55,12 +56,18 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
messageBody: text('messageBody', overrideProps.messageBody || ''), messageBody: text('messageBody', overrideProps.messageBody || ''),
onClose: action('onClose'), onClose: action('onClose'),
onEditorStateChange: action('onEditorStateChange'), onEditorStateChange: action('onEditorStateChange'),
onPickEmoji: action('onPickEmoji'),
onTextTooLong: action('onTextTooLong'),
onSetSkinTone: action('onSetSkinTone'),
recentEmojis: [],
removeLinkPreview: action('removeLinkPreview'), 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), theme: React.useContext(StorybookThemeContext),
regionCode: 'US', regionCode: 'US',
}); });

View File

@ -11,26 +11,21 @@ import React, {
} from 'react'; } from 'react';
import type { MeasuredComponentProps } from 'react-measure'; import type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure'; import Measure from 'react-measure';
import { noop } from 'lodash';
import { animated } from '@react-spring/web'; import { animated } from '@react-spring/web';
import classNames from 'classnames'; import classNames from 'classnames';
import { AttachmentList } from './conversation/AttachmentList'; import { AttachmentList } from './conversation/AttachmentList';
import type { AttachmentType } from '../types/Attachment'; import type { AttachmentType } from '../types/Attachment';
import { Button } from './Button'; import { Button } from './Button';
import type { InputApi } from './CompositionInput';
import { CompositionInput } from './CompositionInput';
import { ConfirmationDialog } from './ConfirmationDialog'; import { ConfirmationDialog } from './ConfirmationDialog';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox'; import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
import type { Row } from './ConversationList'; import type { Row } from './ConversationList';
import { ConversationList, RowType } from './ConversationList'; import { ConversationList, RowType } from './ConversationList';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; 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 { LinkPreviewType } from '../types/message/LinkPreviews';
import type { BodyRangeType, LocalizerType, ThemeType } from '../types/Util'; import type { BodyRangeType, LocalizerType, ThemeType } from '../types/Util';
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
import { ModalHost } from './ModalHost'; import { ModalHost } from './ModalHost';
import { SearchInput } from './SearchInput'; import { SearchInput } from './SearchInput';
import { StagedLinkPreview } from './conversation/StagedLinkPreview'; import { StagedLinkPreview } from './conversation/StagedLinkPreview';
@ -62,15 +57,14 @@ export type DataPropsType = {
bodyRanges: Array<BodyRangeType>, bodyRanges: Array<BodyRangeType>,
caretLocation?: number caretLocation?: number
) => unknown; ) => unknown;
onTextTooLong: () => void;
theme: ThemeType; theme: ThemeType;
regionCode: string | undefined; regionCode: string | undefined;
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>; RenderCompositionTextArea: (
props: SmartCompositionTextAreaProps
) => JSX.Element;
};
type ActionPropsType = Pick< type ActionPropsType = {
EmojiButtonProps,
'onPickEmoji' | 'onSetSkinTone'
> & {
removeLinkPreview: () => void; removeLinkPreview: () => void;
}; };
@ -90,17 +84,12 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
messageBody, messageBody,
onClose, onClose,
onEditorStateChange, onEditorStateChange,
onPickEmoji,
onSetSkinTone,
onTextTooLong,
recentEmojis,
removeLinkPreview, removeLinkPreview,
skinTone, RenderCompositionTextArea,
theme, theme,
regionCode, regionCode,
}) => { }) => {
const inputRef = useRef<null | HTMLInputElement>(null); const inputRef = useRef<null | HTMLInputElement>(null);
const inputApiRef = React.useRef<InputApi | undefined>();
const [selectedContacts, setSelectedContacts] = useState< const [selectedContacts, setSelectedContacts] = useState<
Array<ConversationType> Array<ConversationType>
>([]); >([]);
@ -125,22 +114,6 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
[selectedContacts] [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 hasContactsSelected = Boolean(selectedContacts.length);
const canForwardMessage = const canForwardMessage =
@ -351,40 +324,16 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
}} }}
/> />
) : null} ) : null}
<div className="module-ForwardMessageModal__text-edit-area">
<CompositionInput <RenderCompositionTextArea
clearQuotedMessage={shouldNeverBeCalled} draftText={messageBodyText}
draftText={messageBodyText} onChange={(messageText, bodyRanges, caretLocation?) => {
getPreferredBadge={getPreferredBadge} setMessageBodyText(messageText);
getQuotedMessage={noop} onEditorStateChange(messageText, bodyRanges, caretLocation);
i18n={i18n} }}
inputApi={inputApiRef} onSubmit={forwardMessage}
large theme={theme}
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>
</div> </div>
) : ( ) : (
<div className="module-ForwardMessageModal__main-body"> <div className="module-ForwardMessageModal__main-body">

View File

@ -9,6 +9,7 @@ import { MediaEditor } from './MediaEditor';
import enMessages from '../../_locales/en/messages.json'; import enMessages from '../../_locales/en/messages.json';
import { setupI18n } from '../util/setupI18n'; import { setupI18n } from '../util/setupI18n';
import { Stickers, installedPacks } from '../test-both/helpers/getStickerPacks'; import { Stickers, installedPacks } from '../test-both/helpers/getStickerPacks';
import { CompositionTextArea } from './CompositionTextArea';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -47,3 +48,20 @@ export const Smol = (): JSX.Element => (
export const Portrait = (): JSX.Element => ( export const Portrait = (): JSX.Element => (
<MediaEditor {...getDefaultProps()} imageSrc={IMAGE_4} /> <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}
/>
)}
/>
);

View File

@ -9,6 +9,7 @@ import { fabric } from 'fabric';
import { get, has, noop } from 'lodash'; import { get, has, noop } from 'lodash';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { ThemeType } from '../types/Util';
import type { Props as StickerButtonProps } from './stickers/StickerButton'; import type { Props as StickerButtonProps } from './stickers/StickerButton';
import type { ImageStateType } from '../mediaEditor/ImageStateType'; import type { ImageStateType } from '../mediaEditor/ImageStateType';
@ -33,14 +34,30 @@ import {
TextStyle, TextStyle,
getTextStyleAttributes, getTextStyleAttributes,
} from '../mediaEditor/util/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 = { export type PropsType = {
doneButtonLabel?: string; doneButtonLabel?: string;
i18n: LocalizerType; i18n: LocalizerType;
imageSrc: string; imageSrc: string;
onClose: () => unknown; onClose: () => unknown;
onDone: (data: Uint8Array) => unknown; onDone: (data: Uint8Array, caption?: string | undefined) => unknown;
} & Pick<StickerButtonProps, 'installedPacks' | 'recentStickers'>; } & Pick<StickerButtonProps, 'installedPacks' | 'recentStickers'> &
(
| {
supportsCaption: true;
renderCompositionTextArea: (
props: SmartCompositionTextAreaProps
) => JSX.Element;
}
| {
supportsCaption?: false;
renderCompositionTextArea?: undefined;
}
);
const INITIAL_IMAGE_STATE: ImageStateType = { const INITIAL_IMAGE_STATE: ImageStateType = {
angle: 0, angle: 0,
@ -94,12 +111,17 @@ export const MediaEditor = ({
// StickerButtonProps // StickerButtonProps
installedPacks, installedPacks,
recentStickers, recentStickers,
...props
}: PropsType): JSX.Element | null => { }: PropsType): JSX.Element | null => {
const [fabricCanvas, setFabricCanvas] = useState<fabric.Canvas | undefined>(); const [fabricCanvas, setFabricCanvas] = useState<fabric.Canvas | undefined>();
const [image, setImage] = useState<HTMLImageElement>(new Image()); const [image, setImage] = useState<HTMLImageElement>(new Image());
const [isStickerPopperOpen, setIsStickerPopperOpen] = const [isStickerPopperOpen, setIsStickerPopperOpen] =
useState<boolean>(false); useState<boolean>(false);
const [caption, setCaption] = useState('');
const [showAddCaptionModal, setShowAddCaptionModal] = useState(false);
const canvasId = useUniqueId(); const canvasId = useUniqueId();
const [imageState, setImageState] = const [imageState, setImageState] =
@ -892,7 +914,46 @@ export const MediaEditor = ({
{tooling ? ( {tooling ? (
<div className="MediaEditor__tools">{tooling}</div> <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"> <div className="MediaEditor__toolbar--buttons">
<Button <Button
@ -1087,7 +1148,7 @@ export const MediaEditor = ({
setIsSaving(false); setIsSaving(false);
} }
onDone(data); onDone(data, caption !== '' ? caption : undefined);
}} }}
theme={Theme.Dark} theme={Theme.Dark}
variant={ButtonVariant.Primary} variant={ButtonVariant.Primary}

View File

@ -21,6 +21,8 @@ type PropsType = {
children: ReactNode; children: ReactNode;
modalName: string; modalName: string;
hasXButton?: boolean; hasXButton?: boolean;
hasHeaderDivider?: boolean;
hasFooterDivider?: boolean;
i18n: LocalizerType; i18n: LocalizerType;
modalFooter?: JSX.Element; modalFooter?: JSX.Element;
moduleClassName?: string; moduleClassName?: string;
@ -51,6 +53,8 @@ export function Modal({
theme, theme,
title, title,
useFocusTrap, useFocusTrap,
hasHeaderDivider = false,
hasFooterDivider = false,
padded = true, padded = true,
}: Readonly<ModalPropsType>): ReactElement { }: Readonly<ModalPropsType>): ReactElement {
const { close, modalStyles, overlayStyles } = useAnimated(onClose, { const { close, modalStyles, overlayStyles } = useAnimated(onClose, {
@ -82,6 +86,8 @@ export function Modal({
onClose={close} onClose={close}
title={title} title={title}
padded={padded} padded={padded}
hasHeaderDivider={hasHeaderDivider}
hasFooterDivider={hasFooterDivider}
> >
{children} {children}
</ModalPage> </ModalPage>
@ -120,6 +126,8 @@ export function ModalPage({
onClose, onClose,
title, title,
padded = true, padded = true,
hasHeaderDivider = false,
hasFooterDivider = false,
}: ModalPageProps): JSX.Element { }: ModalPageProps): JSX.Element {
const modalRef = useRef<HTMLDivElement | null>(null); const modalRef = useRef<HTMLDivElement | null>(null);
@ -151,7 +159,10 @@ export function ModalPage({
className={classNames( className={classNames(
getClassName(''), getClassName(''),
getClassName(hasHeader ? '--has-header' : '--no-header'), 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} ref={modalRef}
onClick={event => { onClick={event => {

View File

@ -21,6 +21,7 @@ import { SendStoryModal } from './SendStoryModal';
import { MediaEditor } from './MediaEditor'; import { MediaEditor } from './MediaEditor';
import { TextStoryCreator } from './TextStoryCreator'; import { TextStoryCreator } from './TextStoryCreator';
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
export type PropsType = { export type PropsType = {
debouncedMaybeGrabLinkPreview: ( debouncedMaybeGrabLinkPreview: (
@ -39,6 +40,9 @@ export type PropsType = {
processAttachment: ( processAttachment: (
file: File file: File
) => Promise<void | InMemoryAttachmentDraftType>; ) => Promise<void | InMemoryAttachmentDraftType>;
renderCompositionTextArea: (
props: SmartCompositionTextAreaProps
) => JSX.Element;
sendStoryModalOpenStateChanged: (isOpen: boolean) => unknown; sendStoryModalOpenStateChanged: (isOpen: boolean) => unknown;
} & Pick<StickerButtonProps, 'installedPacks' | 'recentStickers'> & } & Pick<StickerButtonProps, 'installedPacks' | 'recentStickers'> &
Pick< Pick<
@ -87,6 +91,7 @@ export const StoryCreator = ({
onViewersUpdated, onViewersUpdated,
processAttachment, processAttachment,
recentStickers, recentStickers,
renderCompositionTextArea,
sendStoryModalOpenStateChanged, sendStoryModalOpenStateChanged,
setMyStoriesToAllSignalConnections, setMyStoriesToAllSignalConnections,
signalConnections, signalConnections,
@ -174,11 +179,14 @@ export const StoryCreator = ({
imageSrc={attachmentUrl} imageSrc={attachmentUrl}
installedPacks={installedPacks} installedPacks={installedPacks}
onClose={onClose} onClose={onClose}
onDone={data => { supportsCaption
renderCompositionTextArea={renderCompositionTextArea}
onDone={(data, caption) => {
setDraftAttachment({ setDraftAttachment({
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
data, data,
size: data.byteLength, size: data.byteLength,
caption,
}); });
}} }}
recentStickers={recentStickers} recentStickers={recentStickers}

View File

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

View File

@ -9,13 +9,11 @@ import type { StateType } from '../reducer';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { ForwardMessageModal } from '../../components/ForwardMessageModal'; import { ForwardMessageModal } from '../../components/ForwardMessageModal';
import { LinkPreviewSourceType } from '../../types/LinkPreview'; import { LinkPreviewSourceType } from '../../types/LinkPreview';
import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong';
import type { GetConversationByIdType } from '../selectors/conversations'; import type { GetConversationByIdType } from '../selectors/conversations';
import { import {
getAllComposableConversations, getAllComposableConversations,
getConversationSelector, getConversationSelector,
} from '../selectors/conversations'; } from '../selectors/conversations';
import { getEmojiSkinTone } from '../selectors/items';
import { getIntl, getTheme, getRegionCode } from '../selectors/user'; import { getIntl, getTheme, getRegionCode } from '../selectors/user';
import { getLinkPreview } from '../selectors/linkPreviews'; import { getLinkPreview } from '../selectors/linkPreviews';
import { getMessageById } from '../../messages/getMessageById'; import { getMessageById } from '../../messages/getMessageById';
@ -25,14 +23,11 @@ import {
maybeGrabLinkPreview, maybeGrabLinkPreview,
resetLinkPreview, resetLinkPreview,
} from '../../services/LinkPreview'; } 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 { useGlobalModalActions } from '../ducks/globalModals';
import { useLinkPreviewActions } from '../ducks/linkPreviews'; import { useLinkPreviewActions } from '../ducks/linkPreviews';
import { processBodyRanges } from '../selectors/message'; import { processBodyRanges } from '../selectors/message';
import { getTextWithMentions } from '../../util/getTextWithMentions'; import { getTextWithMentions } from '../../util/getTextWithMentions';
import { SmartCompositionTextArea } from './CompositionTextArea';
function renderMentions( function renderMentions(
message: ForwardMessagePropsType, message: ForwardMessagePropsType,
@ -65,14 +60,10 @@ export function SmartForwardMessageModal(): JSX.Element | null {
const getConversation = useSelector(getConversationSelector); const getConversation = useSelector(getConversationSelector);
const i18n = useSelector(getIntl); const i18n = useSelector(getIntl);
const linkPreviewForSource = useSelector(getLinkPreview); const linkPreviewForSource = useSelector(getLinkPreview);
const recentEmojis = useSelector(selectRecentEmojis);
const regionCode = useSelector(getRegionCode); const regionCode = useSelector(getRegionCode);
const skinTone = useSelector(getEmojiSkinTone);
const theme = useSelector(getTheme); const theme = useSelector(getTheme);
const { removeLinkPreview } = useLinkPreviewActions(); const { removeLinkPreview } = useLinkPreviewActions();
const { onUseEmoji: onPickEmoji } = useEmojiActions();
const { onSetSkinTone } = useItemsActions();
const { toggleForwardMessageModal } = useGlobalModalActions(); const { toggleForwardMessageModal } = useGlobalModalActions();
if (!forwardMessageProps) { if (!forwardMessageProps) {
@ -141,13 +132,9 @@ export function SmartForwardMessageModal(): JSX.Element | null {
); );
} }
}} }}
onPickEmoji={onPickEmoji}
onSetSkinTone={onSetSkinTone}
onTextTooLong={() => showToast(ToastMessageBodyTooLong)}
recentEmojis={recentEmojis}
regionCode={regionCode} regionCode={regionCode}
RenderCompositionTextArea={SmartCompositionTextArea}
removeLinkPreview={removeLinkPreview} removeLinkPreview={removeLinkPreview}
skinTone={skinTone}
theme={theme} theme={theme}
/> />
); );

View File

@ -30,6 +30,7 @@ import { useGlobalModalActions } from '../ducks/globalModals';
import { useLinkPreviewActions } from '../ducks/linkPreviews'; import { useLinkPreviewActions } from '../ducks/linkPreviews';
import { useStoriesActions } from '../ducks/stories'; import { useStoriesActions } from '../ducks/stories';
import { useStoryDistributionListsActions } from '../ducks/storyDistributionLists'; import { useStoryDistributionListsActions } from '../ducks/storyDistributionLists';
import { SmartCompositionTextArea } from './CompositionTextArea';
export type PropsType = { export type PropsType = {
file?: File; file?: File;
@ -96,6 +97,7 @@ export function SmartStoryCreator({
onViewersUpdated={updateStoryViewers} onViewersUpdated={updateStoryViewers}
processAttachment={processAttachment} processAttachment={processAttachment}
recentStickers={recentStickers} recentStickers={recentStickers}
renderCompositionTextArea={SmartCompositionTextArea}
sendStoryModalOpenStateChanged={sendStoryModalOpenStateChanged} sendStoryModalOpenStateChanged={sendStoryModalOpenStateChanged}
setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections} setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections}
signalConnections={signalConnections} signalConnections={signalConnections}

View File

@ -1,7 +1,7 @@
// Copyright 2021-2022 Signal Messenger, LLC // Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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> { export function getGraphemes(str: string): Iterable<string> {
const segments = new Intl.Segmenter().segment(str); const segments = new Intl.Segmenter().segment(str);
@ -13,6 +13,25 @@ export function count(str: string): number {
return size(segments); 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 { export function isSingleGrapheme(str: string): boolean {
if (str === '') { if (str === '') {
return false; return false;

View File

@ -15,6 +15,27 @@
"updated": "2018-09-18T19:19:27.699Z", "updated": "2018-09-18T19:19:27.699Z",
"reasonDetail": "Part of runtime library for C++ transpiled code" "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(", "rule": "jQuery-append(",
"path": "components/mp3lameencoder/lib/Mp3LameEncoder.js", "path": "components/mp3lameencoder/lib/Mp3LameEncoder.js",
@ -8986,13 +9007,6 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-07-30T16:57:33.618Z" "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", "rule": "React-useRef",
"path": "ts/components/CompositionInput.tsx", "path": "ts/components/CompositionInput.tsx",
@ -9050,13 +9064,6 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-07-30T16:57:33.618Z" "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", "rule": "React-useRef",
"path": "ts/components/GradientDial.tsx", "path": "ts/components/GradientDial.tsx",