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