Story - add caption

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

View file

@ -15,6 +15,18 @@
}
]
},
"AddCaptionModal__title": {
"message": "Add a message",
"description": "Shown as the title of the dialog that allows you to add a caption to a story"
},
"AddCaptionModal__placeholder": {
"message": "Message",
"description": "Placeholder text for textarea when adding a caption/message (we don't know which yet so we default to message)"
},
"AddCaptionModal__submit-button": {
"message": "Done",
"description": "Label on the button that submits changes to a story's caption in the add-caption dialog"
},
"AddUserToAnotherGroupModal__title": {
"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"

View file

@ -0,0 +1,65 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.CompositionTextArea {
position: relative;
&__input {
&__input {
background: inherit;
border: none;
border-radius: 0;
height: 100%;
&:focus-within {
border: none;
}
@include dark-theme() {
border: none;
&:focus-within {
border: none;
}
}
@include keyboard-mode {
&:focus-within {
border: solid 1px $color-ultramarine;
}
}
}
&__input__scroller {
max-height: 300px;
min-height: 300px;
padding: 16px;
// Need more padding on the right to make room for floating emoji button
padding-right: 36px;
}
}
&__emoji {
position: absolute;
right: 8px;
top: 8px;
button::after {
background-color: $color-black;
}
}
&__remaining-character-count {
@include font-subtitle;
color: $color-gray-45;
position: absolute;
bottom: 0;
left: 0;
padding: 12px 12px 12px 24px;
}
// remove background, should be seamless with modal
.module-composition-input__input {
background: transparent;
}
}

View file

@ -31,41 +31,6 @@
}
}
&__input {
&__input {
background: inherit;
border: none;
border-radius: 0;
height: 100%;
&:focus-within {
border: none;
}
@include dark-theme() {
border: none;
&:focus-within {
border: none;
}
}
@include keyboard-mode {
&:focus-within {
border: solid 1px $color-ultramarine;
}
}
}
&__input__scroller {
max-height: 300px;
min-height: 300px;
padding: 16px;
// Need more padding on the right to make room for floating emoji button
padding-right: 36px;
}
}
&__header {
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;

View file

@ -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 {

View file

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

View file

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

View file

@ -0,0 +1,47 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { Meta, Story } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { Props } from './AddCaptionModal';
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
import { AddCaptionModal } from './AddCaptionModal';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
import enMessages from '../../_locales/en/messages.json';
import { setupI18n } from '../util/setupI18n';
import { CompositionTextArea } from './CompositionTextArea';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/AddCaptionModal',
component: AddCaptionModal,
argTypes: {
i18n: {
defaultValue: i18n,
},
RenderCompositionTextArea: {
defaultValue: (props: SmartCompositionTextAreaProps) => (
<CompositionTextArea
{...props}
i18n={i18n}
onPickEmoji={action('onPickEmoji')}
onChange={action('onChange')}
onTextTooLong={action('onTextTooLong')}
onSetSkinTone={action('onSetSkinTone')}
getPreferredBadge={() => undefined}
/>
),
},
},
} as Meta;
const Template: Story<Props> = args => (
<AddCaptionModal {...args} theme={React.useContext(StorybookThemeContext)} />
);
export const Modal = Template.bind({});
Modal.args = {
draftText: 'Some caption text',
};

View file

@ -0,0 +1,87 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect } from 'react';
import { noop } from 'lodash';
import { Button } from './Button';
import { Modal } from './Modal';
import type { LocalizerType, ThemeType } from '../types/Util';
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
export type Props = {
i18n: LocalizerType;
onClose: () => void;
onSubmit: (text: string) => void;
draftText: string;
theme: ThemeType;
RenderCompositionTextArea: (
props: SmartCompositionTextAreaProps
) => JSX.Element;
};
export const AddCaptionModal = ({
i18n,
onClose,
onSubmit,
draftText,
RenderCompositionTextArea,
theme,
}: Props): JSX.Element => {
const [messageText, setMessageText] = React.useState('');
const [isScrolledTop, setIsScrolledTop] = React.useState(true);
const [isScrolledBottom, setIsScrolledBottom] = React.useState(true);
const scrollerRef = React.useRef<HTMLDivElement>(null);
// add footer/header dividers depending on the state of scroll
const updateScrollState = React.useCallback(() => {
const scrollerEl = scrollerRef.current;
if (scrollerEl) {
setIsScrolledTop(scrollerEl.scrollTop === 0);
setIsScrolledBottom(
scrollerEl.scrollHeight - scrollerEl.scrollTop ===
scrollerEl.clientHeight
);
}
}, [scrollerRef]);
useEffect(() => {
updateScrollState();
}, [updateScrollState]);
const handleSubmit = React.useCallback(() => {
onSubmit(messageText);
}, [messageText, onSubmit]);
return (
<Modal
i18n={i18n}
modalName="AddCaptionModal"
hasXButton
hasHeaderDivider={!isScrolledTop}
hasFooterDivider={!isScrolledBottom}
moduleClassName="AddCaptionModal"
padded={false}
title="Add a message"
onClose={onClose}
modalFooter={
<Button onClick={handleSubmit}>
{i18n('AddCaptionModal__submit-button')}
</Button>
}
>
<RenderCompositionTextArea
maxLength={1500}
whenToShowRemainingCount={1450}
placeholder={i18n('AddCaptionModal__placeholder')}
onChange={setMessageText}
scrollerRef={scrollerRef}
draftText={draftText}
onSubmit={noop}
onScroll={updateScrollState}
theme={theme}
/>
</Modal>
);
};

View file

@ -1,8 +1,8 @@
// Copyright 2022 Signal Messenger, LLC
// 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';

View file

@ -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,

View file

@ -0,0 +1,161 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { noop } from 'lodash';
import React from 'react';
import type { LocalizerType } from '../types/I18N';
import type { EmojiPickDataType } from './emoji/EmojiPicker';
import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
import type { InputApi } from './CompositionInput';
import { CompositionInput } from './CompositionInput';
import { EmojiButton } from './emoji/EmojiButton';
import type { BodyRangeType, ThemeType } from '../types/Util';
import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import * as grapheme from '../util/grapheme';
export type CompositionTextAreaProps = {
i18n: LocalizerType;
maxLength?: number;
placeholder?: string;
whenToShowRemainingCount?: number;
scrollerRef?: React.RefObject<HTMLDivElement>;
onScroll?: (ev: React.UIEvent<HTMLElement, UIEvent>) => void;
onPickEmoji: (e: EmojiPickDataType) => void;
onChange: (
messageText: string,
bodyRanges: Array<BodyRangeType>,
caretLocation?: number | undefined
) => void;
onSetSkinTone: (tone: number) => void;
onSubmit: (
message: string,
mentions: Array<BodyRangeType>,
timestamp: number
) => void;
onTextTooLong: () => void;
getPreferredBadge: PreferredBadgeSelectorType;
draftText: string;
theme: ThemeType;
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
/**
* Essentially an HTML textarea but with support for emoji picker and
* at-mentions autocomplete.
*
* Meant for modals that need to collect a message or caption. It is
* basically a rectangle input with an emoji selector floating at the top-right
*/
export const CompositionTextArea = ({
i18n,
placeholder,
maxLength,
whenToShowRemainingCount = Infinity,
scrollerRef,
onScroll,
onPickEmoji,
onChange,
onSetSkinTone,
onSubmit,
onTextTooLong,
getPreferredBadge,
draftText,
theme,
recentEmojis,
skinTone,
}: CompositionTextAreaProps): JSX.Element => {
const inputApiRef = React.useRef<InputApi | undefined>();
const [characterCount, setCharacterCount] = React.useState(
grapheme.count(draftText)
);
const insertEmoji = React.useCallback(
(e: EmojiPickDataType) => {
if (inputApiRef.current) {
inputApiRef.current.insertEmoji(e);
onPickEmoji(e);
}
},
[inputApiRef, onPickEmoji]
);
const focusTextEditInput = React.useCallback(() => {
if (inputApiRef.current) {
inputApiRef.current.focus();
}
}, [inputApiRef]);
const handleChange = React.useCallback(
(
newValue: string,
bodyRanges: Array<BodyRangeType>,
caretLocation?: number | undefined
) => {
const inputEl = inputApiRef.current;
if (!inputEl) {
return;
}
const [newValueSized, newCharacterCount] = grapheme.truncateAndSize(
newValue,
maxLength
);
if (maxLength !== undefined) {
// if we had to truncate
if (newValueSized.length < newValue.length) {
// reset quill to the value before the change that pushed it over the max
// and push the cursor to the end
//
// this is not perfect as it pushes the cursor to the end, even if the user
// was modifying text in the middle of the editor
// a better solution would be to prevent the change to begin with, but
// quill makes this VERY difficult
inputEl.setText(newValueSized, true);
}
}
setCharacterCount(newCharacterCount);
onChange(newValue, bodyRanges, caretLocation);
},
[maxLength, onChange]
);
return (
<div className="CompositionTextArea">
<CompositionInput
placeholder={placeholder}
clearQuotedMessage={shouldNeverBeCalled}
scrollerRef={scrollerRef}
getPreferredBadge={getPreferredBadge}
getQuotedMessage={noop}
i18n={i18n}
inputApi={inputApiRef}
large
moduleClassName="CompositionTextArea__input"
onScroll={onScroll}
onEditorStateChange={handleChange}
onPickEmoji={onPickEmoji}
onSubmit={onSubmit}
onTextTooLong={onTextTooLong}
draftText={draftText}
theme={theme}
/>
<div className="CompositionTextArea__emoji">
<EmojiButton
i18n={i18n}
onClose={focusTextEditInput}
onPickEmoji={insertEmoji}
onSetSkinTone={onSetSkinTone}
recentEmojis={recentEmojis}
skinTone={skinTone}
/>
</div>
{maxLength !== undefined &&
characterCount >= whenToShowRemainingCount && (
<div className="CompositionTextArea__remaining-character-count">
{maxLength - characterCount}
</div>
)}
</div>
);
};

View file

@ -14,6 +14,7 @@ import { IMAGE_JPEG, VIDEO_MP4, stringToMIMEType } from '../types/MIME';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { 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',
});

View file

@ -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">

View file

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

View file

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

View file

@ -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 => {

View file

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

View file

@ -0,0 +1,50 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { useSelector } from 'react-redux';
import type { CompositionTextAreaProps } from '../../components/CompositionTextArea';
import { CompositionTextArea } from '../../components/CompositionTextArea';
import type { LocalizerType } from '../../types/I18N';
import type { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { useActions as useEmojiActions } from '../ducks/emojis';
import { useActions as useItemsActions } from '../ducks/items';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { showToast } from '../../util/showToast';
import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong';
export type SmartCompositionTextAreaProps = Pick<
CompositionTextAreaProps,
| 'draftText'
| 'placeholder'
| 'onChange'
| 'onScroll'
| 'onSubmit'
| 'theme'
| 'maxLength'
| 'whenToShowRemainingCount'
| 'scrollerRef'
>;
export const SmartCompositionTextArea = (
props: SmartCompositionTextAreaProps
): JSX.Element => {
const i18n = useSelector<StateType, LocalizerType>(getIntl);
const { onUseEmoji: onPickEmoji } = useEmojiActions();
const { onSetSkinTone } = useItemsActions();
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
return (
<CompositionTextArea
{...props}
i18n={i18n}
onPickEmoji={onPickEmoji}
onSetSkinTone={onSetSkinTone}
getPreferredBadge={getPreferredBadge}
onTextTooLong={() => showToast(ToastMessageBodyTooLong)}
/>
);
};

View file

@ -9,13 +9,11 @@ import type { StateType } from '../reducer';
import * as log from '../../logging/log';
import { 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}
/>
);

View file

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

View file

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

View file

@ -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",