diff --git a/stylesheets/components/StoryCreator.scss b/stylesheets/components/StoryCreator.scss index 881a40093..3af956a78 100644 --- a/stylesheets/components/StoryCreator.scss +++ b/stylesheets/components/StoryCreator.scss @@ -316,4 +316,10 @@ width: 24px; } } + + &__emoji-button, + &__emoji-button::after { + height: 20px; + width: 20px; + } } diff --git a/ts/components/StoryCreator.stories.tsx b/ts/components/StoryCreator.stories.tsx index 0b11f0dc8..35f35aaa2 100644 --- a/ts/components/StoryCreator.stories.tsx +++ b/ts/components/StoryCreator.stories.tsx @@ -55,8 +55,13 @@ export default { onDistributionListCreated: { action: true }, onHideMyStoriesFrom: { action: true }, onSend: { action: true }, + onSetSkinTone: { action: true }, + onUseEmoji: { action: true }, onViewersUpdated: { action: true }, processAttachment: { action: true }, + recentEmojis: { + defaultValue: [], + }, recentStickers: { defaultValue: [], }, @@ -65,6 +70,9 @@ export default { signalConnections: { defaultValue: Array.from(Array(42), getDefaultConversation), }, + skinTone: { + defaultValue: 0, + }, toggleSignalConnectionsModal: { action: true }, }, } as Meta; diff --git a/ts/components/StoryCreator.tsx b/ts/components/StoryCreator.tsx index bc9bcc7e8..1da82cb62 100644 --- a/ts/components/StoryCreator.tsx +++ b/ts/components/StoryCreator.tsx @@ -15,6 +15,7 @@ import type { Props as StickerButtonProps } from './stickers/StickerButton'; import type { PropsType as SendStoryModalPropsType } from './SendStoryModal'; import type { UUIDStringType } from '../types/UUID'; import type { imageToBlurHash } from '../util/imageToBlurHash'; +import type { PropsType as TextStoryCreatorPropsType } from './TextStoryCreator'; import { TEXT_ATTACHMENT } from '../types/MIME'; import { isVideoAttachment } from '../types/Attachment'; @@ -70,6 +71,10 @@ export type PropsType = { | 'toggleGroupsForStorySend' | 'mostRecentActiveStoryTimestampByGroupOrDistributionList' | 'toggleSignalConnectionsModal' + > & + Pick< + TextStoryCreatorPropsType, + 'onUseEmoji' | 'skinTone' | 'onSetSkinTone' | 'recentEmojis' >; export function StoryCreator({ @@ -87,7 +92,7 @@ export function StoryCreator({ isSending, linkPreview, me, - ourConversationId, + mostRecentActiveStoryTimestampByGroupOrDistributionList, onClose, onDeleteList, onDistributionListCreated, @@ -96,15 +101,19 @@ export function StoryCreator({ onRepliesNReactionsChanged, onSelectedStoryList, onSend, + onSetSkinTone, + onUseEmoji, onViewersUpdated, + ourConversationId, processAttachment, + recentEmojis, recentStickers, renderCompositionTextArea, sendStoryModalOpenStateChanged, setMyStoriesToAllSignalConnections, signalConnections, + skinTone, toggleGroupsForStorySend, - mostRecentActiveStoryTimestampByGroupOrDistributionList, toggleSignalConnectionsModal, }: PropsType): JSX.Element { const [draftAttachment, setDraftAttachment] = useState< @@ -236,6 +245,10 @@ export function StoryCreator({ }); setIsReadyToSend(true); }} + onUseEmoji={onUseEmoji} + onSetSkinTone={onSetSkinTone} + recentEmojis={recentEmojis} + skinTone={skinTone} /> )} diff --git a/ts/components/TextAttachment.tsx b/ts/components/TextAttachment.tsx index d2a09c2f1..d646bf523 100644 --- a/ts/components/TextAttachment.tsx +++ b/ts/components/TextAttachment.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import Measure from 'react-measure'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { forwardRef, useEffect, useRef, useState } from 'react'; import TextareaAutosize from 'react-textarea-autosize'; import classNames from 'classnames'; @@ -21,6 +21,7 @@ import { getBackgroundColor, } from '../util/getStoryBackground'; import { SECOND } from '../util/durations'; +import { useRefMerger } from '../hooks/useRefMerger'; const renderNewLines: RenderTextCallbackType = ({ text: textWithNewLines, @@ -105,207 +106,214 @@ function getTextStyles( }; } -export function TextAttachment({ - disableLinkPreviewPopup, - i18n, - isEditingText, - isThumbnail, - onChange, - onClick, - onRemoveLinkPreview, - textAttachment, -}: PropsType): JSX.Element | null { - const linkPreview = useRef(null); - const [linkPreviewOffsetTop, setLinkPreviewOffsetTop] = useState< - number | undefined - >(); +export const TextAttachment = forwardRef( + function TextAttachmentForwarded( + { + disableLinkPreviewPopup, + i18n, + isEditingText, + isThumbnail, + onChange, + onClick, + onRemoveLinkPreview, + textAttachment, + }, + forwardedTextEditorRef + ): JSX.Element | null { + const linkPreview = useRef(null); + const [linkPreviewOffsetTop, setLinkPreviewOffsetTop] = useState< + number | undefined + >(); - const textContent = textAttachment.text || ''; + const textContent = textAttachment.text || ''; + const textEditorRef = useRef(null); + const refMerger = useRefMerger(); - const textEditorRef = useRef(null); - - useEffect(() => { - const node = textEditorRef.current; - if (!node) { - return; - } - - node.focus(); - node.setSelectionRange(node.value.length, node.value.length); - }, [isEditingText]); - - useEffect(() => { - setLinkPreviewOffsetTop(undefined); - }, [textAttachment.preview?.url]); - - const [isHoveringOverTooltip, setIsHoveringOverTooltip] = useState(false); - - function showTooltip() { - if (disableLinkPreviewPopup) { - return; - } - setIsHoveringOverTooltip(true); - setLinkPreviewOffsetTop(linkPreview?.current?.offsetTop); - } - - useEffect(() => { - const timeout = setTimeout(() => { - if (!isHoveringOverTooltip) { - setLinkPreviewOffsetTop(undefined); + useEffect(() => { + const node = textEditorRef?.current; + if (!node) { + return; } - }, 5 * SECOND); - return () => { - clearTimeout(timeout); + node.focus(); + node.setSelectionRange(node.value.length, node.value.length); + }, [isEditingText]); + + useEffect(() => { + setLinkPreviewOffsetTop(undefined); + }, [textAttachment.preview?.url]); + + const [isHoveringOverTooltip, setIsHoveringOverTooltip] = useState(false); + + function showTooltip() { + if (disableLinkPreviewPopup) { + return; + } + setIsHoveringOverTooltip(true); + setLinkPreviewOffsetTop(linkPreview?.current?.offsetTop); + } + + useEffect(() => { + const timeout = setTimeout(() => { + if (!isHoveringOverTooltip) { + setLinkPreviewOffsetTop(undefined); + } + }, 5 * SECOND); + + return () => { + clearTimeout(timeout); + }; + }, [isHoveringOverTooltip]); + + const storyBackgroundColor = { + background: getBackgroundColor(textAttachment), }; - }, [isHoveringOverTooltip]); - const storyBackgroundColor = { - background: getBackgroundColor(textAttachment), - }; + return ( + + {({ contentRect, measureRef }) => { + const scaleFactor = (contentRect.bounds?.height || 1) / 1280; - return ( - - {({ contentRect, measureRef }) => { - const scaleFactor = (contentRect.bounds?.height || 1) / 1280; - - return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -
{ - if (linkPreviewOffsetTop) { - setLinkPreviewOffsetTop(undefined); - } - onClick?.(); - }} - onKeyUp={ev => { - if (ev.key === 'Escape' && linkPreviewOffsetTop) { - setLinkPreviewOffsetTop(undefined); - } - }} - ref={measureRef} - style={isThumbnail ? storyBackgroundColor : undefined} - > - {/* + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
{ + if (linkPreviewOffsetTop) { + setLinkPreviewOffsetTop(undefined); + } + onClick?.(); + }} + onKeyUp={ev => { + if (ev.key === 'Escape' && linkPreviewOffsetTop) { + setLinkPreviewOffsetTop(undefined); + } + }} + ref={measureRef} + style={isThumbnail ? storyBackgroundColor : undefined} + > + {/* The tooltip must be outside of the scaled area, as it should not scale with the story, but it must be positioned using the scaled offset */} - {textAttachment.preview && - textAttachment.preview.url && - linkPreviewOffsetTop && - !isThumbnail && ( - -
-
- {i18n('TextAttachment__preview__link')} -
-
- {textAttachment.preview.url} + {textAttachment.preview && + textAttachment.preview.url && + linkPreviewOffsetTop && + !isThumbnail && ( + +
+
+ {i18n('TextAttachment__preview__link')} +
+
+ {textAttachment.preview.url} +
+
+ + )} +
+ {(textContent || onChange) && ( +
+ {onChange ? ( + onChange(ev.currentTarget.value)} + placeholder={i18n('TextAttachment__placeholder')} + ref={refMerger(forwardedTextEditorRef, textEditorRef)} + style={getTextStyles( + textContent, + textAttachment.textForegroundColor, + textAttachment.textStyle, + i18n + )} + value={textContent} + /> + ) : ( +
+ +
+ )}
-
- - )} -
- {(textContent || onChange) && ( -
- {onChange ? ( - onChange(ev.currentTarget.value)} - placeholder={i18n('TextAttachment__placeholder')} - ref={textEditorRef} - style={getTextStyles( - textContent, - textAttachment.textForegroundColor, - textAttachment.textStyle, - i18n - )} - value={textContent} + )} + {textAttachment.preview && textAttachment.preview.url && ( +
setIsHoveringOverTooltip(false)} + onFocus={showTooltip} + onMouseOut={() => setIsHoveringOverTooltip(false)} + onMouseOver={showTooltip} + > + {onRemoveLinkPreview && ( +
+
+ )} + - ) : ( -
- -
- )} -
- )} - {textAttachment.preview && textAttachment.preview.url && ( -
setIsHoveringOverTooltip(false)} - onFocus={showTooltip} - onMouseOut={() => setIsHoveringOverTooltip(false)} - onMouseOver={showTooltip} - > - {onRemoveLinkPreview && ( -
-
- )} - -
- )} +
+ )} +
-
- ); - }} - - ); -} + ); + }} + + ); + } +); diff --git a/ts/components/TextStoryCreator.tsx b/ts/components/TextStoryCreator.tsx index 0a7f74a51..9820bef51 100644 --- a/ts/components/TextStoryCreator.tsx +++ b/ts/components/TextStoryCreator.tsx @@ -7,12 +7,15 @@ import classNames from 'classnames'; import { get, has, noop } from 'lodash'; import { usePopper } from 'react-popper'; +import type { EmojiPickDataType } from './emoji/EmojiPicker'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; import type { LocalizerType } from '../types/Util'; +import type { Props as EmojiButtonPropsType } from './emoji/EmojiButton'; import type { TextAttachmentType } from '../types/Attachment'; import { Button, ButtonVariant } from './Button'; import { ContextMenu } from './ContextMenu'; +import { EmojiButton } from './emoji/EmojiButton'; import { LinkPreviewSourceType, findLinks } from '../types/LinkPreview'; import type { MaybeGrabLinkPreviewOptionsType } from '../types/LinkPreview'; import { Input } from './Input'; @@ -26,6 +29,7 @@ import { COLOR_WHITE_INT, getBackgroundColor, } from '../util/getStoryBackground'; +import { convertShortName } from './emoji/lib'; import { objectMap } from '../util/objectMap'; import { handleOutsideClick } from '../util/handleOutsideClick'; import { ConfirmDiscardDialog } from './ConfirmDiscardDialog'; @@ -42,7 +46,8 @@ export type PropsType = { linkPreview?: LinkPreviewType; onClose: () => unknown; onDone: (textAttachment: TextAttachmentType) => unknown; -}; + onUseEmoji: (_: EmojiPickDataType) => unknown; +} & Pick; enum LinkPreviewApplied { None = 'None', @@ -128,6 +133,10 @@ export function TextStoryCreator({ linkPreview, onClose, onDone, + onSetSkinTone, + onUseEmoji, + recentEmojis, + skinTone, }: PropsType): JSX.Element { const [showConfirmDiscardModal, setShowConfirmDiscardModal] = useState(false); @@ -145,16 +154,6 @@ export function TextStoryCreator({ const [sliderValue, setSliderValue] = useState(100); const [text, setText] = useState(''); - const textEditorRef = useRef(null); - - useEffect(() => { - if (isEditingText) { - textEditorRef.current?.focus(); - } else { - textEditorRef.current?.blur(); - } - }, [isEditingText]); - const [isColorPickerShowing, setIsColorPickerShowing] = useState(false); const [colorPickerPopperButtonRef, setColorPickerPopperButtonRef] = useState(null); @@ -328,6 +327,8 @@ export function TextStoryCreator({ const hasChanges = Boolean(text || hasLinkPreviewApplied); + const textEditorRef = useRef(null); + return (
@@ -345,6 +346,7 @@ export function TextStoryCreator({ onRemoveLinkPreview={() => { setLinkPreviewApplied(LinkPreviewApplied.None); }} + ref={textEditorRef} textAttachment={textAttachment} />
@@ -428,6 +430,26 @@ export function TextStoryCreator({ }} type="button" /> + { + onUseEmoji(data); + const emoji = convertShortName(data.shortName, data.skinTone); + const insertAt = + textEditorRef.current?.selectionEnd ?? text.length; + setText( + originalText => + `${originalText.substr( + 0, + insertAt + )}${emoji}${originalText.substr(insertAt, text.length)}` + ); + }} + recentEmojis={recentEmojis} + skinTone={skinTone} + onSetSkinTone={onSetSkinTone} + />
) : (
diff --git a/ts/state/smart/StoryCreator.tsx b/ts/state/smart/StoryCreator.tsx index 34bced922..790e16f76 100644 --- a/ts/state/smart/StoryCreator.tsx +++ b/ts/state/smart/StoryCreator.tsx @@ -7,6 +7,7 @@ import { useSelector } from 'react-redux'; import type { LocalizerType } from '../../types/Util'; import type { StateType } from '../reducer'; import { LinkPreviewSourceType } from '../../types/LinkPreview'; +import { SmartCompositionTextArea } from './CompositionTextArea'; import { StoryCreator } from '../../components/StoryCreator'; import { getAllSignalConnections, @@ -22,18 +23,23 @@ import { getInstalledStickerPacks, getRecentStickers, } from '../selectors/stickers'; -import { getHasSetMyStoriesPrivacy } from '../selectors/items'; +import { getAddStoryData } from '../selectors/stories'; +import { + getEmojiSkinTone, + getHasSetMyStoriesPrivacy, +} from '../selectors/items'; import { getLinkPreview } from '../selectors/linkPreviews'; import { getPreferredBadgeSelector } from '../selectors/badges'; -import { processAttachment } from '../../util/processAttachment'; import { imageToBlurHash } from '../../util/imageToBlurHash'; +import { processAttachment } from '../../util/processAttachment'; import { useConversationsActions } from '../ducks/conversations'; +import { useActions as useEmojisActions } from '../ducks/emojis'; import { useGlobalModalActions } from '../ducks/globalModals'; +import { useActions as useItemsActions } from '../ducks/items'; import { useLinkPreviewActions } from '../ducks/linkPreviews'; +import { useRecentEmojis } from '../selectors/emojis'; import { useStoriesActions } from '../ducks/stories'; import { useStoryDistributionListsActions } from '../ducks/storyDistributionLists'; -import { SmartCompositionTextArea } from './CompositionTextArea'; -import { getAddStoryData } from '../selectors/stories'; export type PropsType = { file?: File; @@ -81,6 +87,11 @@ export function SmartStoryCreator(): JSX.Element | null { const file = addStoryData?.type === 'Media' ? addStoryData.file : undefined; const isSending = addStoryData?.sending || false; + const recentEmojis = useRecentEmojis(); + const skinTone = useSelector(getEmojiSkinTone); + const { onSetSkinTone } = useItemsActions(); + const { onUseEmoji } = useEmojisActions(); + return ( setAddStoryData(undefined)} onDeleteList={deleteDistributionList} onDistributionListCreated={createDistributionList} @@ -106,17 +119,19 @@ export function SmartStoryCreator(): JSX.Element | null { onRepliesNReactionsChanged={allowsRepliesChanged} onSelectedStoryList={verifyStoryListMembers} onSend={sendStoryMessage} + onSetSkinTone={onSetSkinTone} + onUseEmoji={onUseEmoji} onViewersUpdated={updateStoryViewers} + ourConversationId={ourConversationId} processAttachment={processAttachment} + recentEmojis={recentEmojis} recentStickers={recentStickers} renderCompositionTextArea={SmartCompositionTextArea} sendStoryModalOpenStateChanged={sendStoryModalOpenStateChanged} setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections} signalConnections={signalConnections} + skinTone={skinTone} toggleGroupsForStorySend={toggleGroupsForStorySend} - mostRecentActiveStoryTimestampByGroupOrDistributionList={ - mostRecentActiveStoryTimestampByGroupOrDistributionList - } toggleSignalConnectionsModal={toggleSignalConnectionsModal} /> ); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index a2faff066..ee8bc5e35 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -9093,7 +9093,7 @@ { "rule": "React-useRef", "path": "ts/components/TextStoryCreator.tsx", - "line": " const textEditorRef = useRef(null);", + "line": " const textEditorRef = useRef(null);", "reasonCategory": "usageTrusted", "updated": "2022-06-16T23:23:32.306Z" },