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