Story - add caption
This commit is contained in:
parent
8fcd36e30a
commit
c52fe3f377
|
@ -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"
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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',
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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';
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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',
|
||||||
});
|
});
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Reference in New Issue