signal-desktop/ts/components/TextStoryCreator.tsx
2023-03-29 17:03:25 -07:00

621 lines
20 KiB
TypeScript

// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import FocusTrap from 'focus-trap-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
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';
import { Slider } from './Slider';
import { StoryLinkPreview } from './StoryLinkPreview';
import { TextAttachment } from './TextAttachment';
import { Theme, themeClassName } from '../util/theme';
import { getRGBA, getRGBANumber } from '../mediaEditor/util/color';
import {
COLOR_BLACK_INT,
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';
import { Spinner } from './Spinner';
export type PropsType = {
debouncedMaybeGrabLinkPreview: (
message: string,
source: LinkPreviewSourceType,
options?: MaybeGrabLinkPreviewOptionsType
) => unknown;
i18n: LocalizerType;
isSending: boolean;
linkPreview?: LinkPreviewType;
onClose: () => unknown;
onDone: (textAttachment: TextAttachmentType) => unknown;
onUseEmoji: (_: EmojiPickDataType) => unknown;
} & Pick<EmojiButtonPropsType, 'onSetSkinTone' | 'recentEmojis' | 'skinTone'>;
enum LinkPreviewApplied {
None = 'None',
Automatic = 'Automatic',
Manual = 'Manual',
}
enum TextStyle {
Default,
Regular,
Bold,
Serif,
Script,
Condensed,
}
enum TextBackground {
None,
Background,
Inverse,
}
const BackgroundStyle = {
BG1: { color: 4285041620 },
BG2: { color: 4287006657 },
BG3: { color: 4290019212 },
BG4: { color: 4287205768 },
BG5: { color: 4283667331 },
BG6: {
angle: 180,
startColor: 4279871994,
endColor: 4294951785,
},
BG7: {
angle: 180,
startColor: 4282660824,
endColor: 4294938254,
},
BG8: {
angle: 180,
startColor: 4278206532,
endColor: 4287871076,
},
};
type BackgroundStyleType = typeof BackgroundStyle[keyof typeof BackgroundStyle];
function getBackground(
bgStyle: BackgroundStyleType
): Pick<TextAttachmentType, 'color' | 'gradient'> {
if (has(bgStyle, 'color')) {
return { color: get(bgStyle, 'color') };
}
const angle = get(bgStyle, 'angle');
const startColor = get(bgStyle, 'startColor');
const endColor = get(bgStyle, 'endColor');
return {
gradient: { angle, startColor, endColor },
};
}
function getBgButtonAriaLabel(
i18n: LocalizerType,
textBackground: TextBackground
): string {
if (textBackground === TextBackground.Background) {
return i18n('icu:StoryCreator__text-bg--background');
}
if (textBackground === TextBackground.Inverse) {
return i18n('icu:StoryCreator__text-bg--inverse');
}
return i18n('icu:StoryCreator__text-bg--none');
}
export function TextStoryCreator({
debouncedMaybeGrabLinkPreview,
i18n,
isSending,
linkPreview,
onClose,
onDone,
onSetSkinTone,
onUseEmoji,
recentEmojis,
skinTone,
}: PropsType): JSX.Element {
const [showConfirmDiscardModal, setShowConfirmDiscardModal] = useState(false);
const onTryClose = useCallback(() => {
setShowConfirmDiscardModal(true);
}, [setShowConfirmDiscardModal]);
const [isEditingText, setIsEditingText] = useState(false);
const [selectedBackground, setSelectedBackground] =
useState<BackgroundStyleType>(BackgroundStyle.BG1);
const [textStyle, setTextStyle] = useState<TextStyle>(TextStyle.Regular);
const [textBackground, setTextBackground] = useState<TextBackground>(
TextBackground.None
);
const [sliderValue, setSliderValue] = useState<number>(100);
const [text, setText] = useState<string>('');
const [isColorPickerShowing, setIsColorPickerShowing] = useState(false);
const [colorPickerPopperButtonRef, setColorPickerPopperButtonRef] =
useState<HTMLButtonElement | null>(null);
const [colorPickerPopperRef, setColorPickerPopperRef] =
useState<HTMLDivElement | null>(null);
const colorPickerPopper = usePopper(
colorPickerPopperButtonRef,
colorPickerPopperRef,
{
modifiers: [
{
name: 'arrow',
},
],
placement: 'top',
strategy: 'fixed',
}
);
const [linkPreviewApplied, setLinkPreviewApplied] = useState(
LinkPreviewApplied.None
);
const hasLinkPreviewApplied = linkPreviewApplied !== LinkPreviewApplied.None;
const [linkPreviewInputValue, setLinkPreviewInputValue] = useState('');
useEffect(() => {
if (!linkPreviewInputValue) {
return;
}
if (linkPreviewApplied === LinkPreviewApplied.Manual) {
return;
}
debouncedMaybeGrabLinkPreview(
linkPreviewInputValue,
LinkPreviewSourceType.StoryCreator,
{
mode: 'story',
}
);
}, [
debouncedMaybeGrabLinkPreview,
linkPreviewApplied,
linkPreviewInputValue,
]);
useEffect(() => {
if (!text) {
return;
}
if (linkPreviewApplied === LinkPreviewApplied.Manual) {
return;
}
debouncedMaybeGrabLinkPreview(text, LinkPreviewSourceType.StoryCreator);
}, [debouncedMaybeGrabLinkPreview, linkPreviewApplied, text]);
useEffect(() => {
if (!linkPreview || !text) {
return;
}
const links = findLinks(text);
const shouldApplyLinkPreview = links.includes(linkPreview.url);
setLinkPreviewApplied(oldValue => {
if (oldValue === LinkPreviewApplied.Manual) {
return oldValue;
}
if (shouldApplyLinkPreview) {
return LinkPreviewApplied.Automatic;
}
return LinkPreviewApplied.None;
});
}, [linkPreview, text]);
const [isLinkPreviewInputShowing, setIsLinkPreviewInputShowing] =
useState(false);
const [linkPreviewInputPopperButtonRef, setLinkPreviewInputPopperButtonRef] =
useState<HTMLButtonElement | null>(null);
const [linkPreviewInputPopperRef, setLinkPreviewInputPopperRef] =
useState<HTMLDivElement | null>(null);
const linkPreviewInputPopper = usePopper(
linkPreviewInputPopperButtonRef,
linkPreviewInputPopperRef,
{
modifiers: [
{
name: 'arrow',
},
],
placement: 'top',
strategy: 'fixed',
}
);
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (
isColorPickerShowing ||
isEditingText ||
isLinkPreviewInputShowing
) {
setIsColorPickerShowing(false);
setIsEditingText(false);
setIsLinkPreviewInputShowing(false);
} else {
onTryClose();
}
event.preventDefault();
event.stopPropagation();
}
};
const useCapture = true;
document.addEventListener('keydown', handleEscape, useCapture);
return () => {
document.removeEventListener('keydown', handleEscape, useCapture);
};
}, [
isColorPickerShowing,
isEditingText,
isLinkPreviewInputShowing,
colorPickerPopperButtonRef,
showConfirmDiscardModal,
setShowConfirmDiscardModal,
onTryClose,
]);
useEffect(() => {
if (!isColorPickerShowing) {
return noop;
}
return handleOutsideClick(
() => {
setIsColorPickerShowing(false);
return true;
},
{
containerElements: [colorPickerPopperRef, colorPickerPopperButtonRef],
name: 'TextStoryCreator.colorPicker',
}
);
}, [isColorPickerShowing, colorPickerPopperRef, colorPickerPopperButtonRef]);
const sliderColorNumber = getRGBANumber(sliderValue);
let textForegroundColor = sliderColorNumber;
let textBackgroundColor: number | undefined;
if (textBackground === TextBackground.Background) {
textBackgroundColor = COLOR_WHITE_INT;
textForegroundColor =
sliderValue >= 95 ? COLOR_BLACK_INT : sliderColorNumber;
} else if (textBackground === TextBackground.Inverse) {
textBackgroundColor =
sliderValue >= 95 ? COLOR_BLACK_INT : sliderColorNumber;
textForegroundColor = COLOR_WHITE_INT;
}
const textAttachment: TextAttachmentType = {
...getBackground(selectedBackground),
text,
textStyle,
textForegroundColor,
textBackgroundColor,
preview: hasLinkPreviewApplied ? linkPreview : undefined,
};
const hasChanges = Boolean(text || hasLinkPreviewApplied);
const textEditorRef = useRef<HTMLTextAreaElement | null>(null);
return (
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
<div className="StoryCreator">
<div className="StoryCreator__container">
<TextAttachment
disableLinkPreviewPopup
i18n={i18n}
isEditingText={isEditingText}
onChange={setText}
onClick={() => {
if (!isEditingText) {
setIsEditingText(true);
}
}}
onRemoveLinkPreview={() => {
setLinkPreviewApplied(LinkPreviewApplied.None);
}}
ref={textEditorRef}
textAttachment={textAttachment}
/>
</div>
<div className="StoryCreator__toolbar">
{isEditingText ? (
<div className="StoryCreator__tools">
<Slider
handleStyle={{ backgroundColor: getRGBA(sliderValue) }}
label={getRGBA(sliderValue)}
moduleClassName="HueSlider StoryCreator__tools__tool"
onChange={setSliderValue}
value={sliderValue}
/>
<ContextMenu
i18n={i18n}
menuOptions={[
{
icon: 'StoryCreator__icon--font-regular',
label: i18n('icu:StoryCreator__text--regular'),
onClick: () => setTextStyle(TextStyle.Regular),
value: TextStyle.Regular,
},
{
icon: 'StoryCreator__icon--font-bold',
label: i18n('icu:StoryCreator__text--bold'),
onClick: () => setTextStyle(TextStyle.Bold),
value: TextStyle.Bold,
},
{
icon: 'StoryCreator__icon--font-serif',
label: i18n('icu:StoryCreator__text--serif'),
onClick: () => setTextStyle(TextStyle.Serif),
value: TextStyle.Serif,
},
{
icon: 'StoryCreator__icon--font-script',
label: i18n('icu:StoryCreator__text--script'),
onClick: () => setTextStyle(TextStyle.Script),
value: TextStyle.Script,
},
{
icon: 'StoryCreator__icon--font-condensed',
label: i18n('icu:StoryCreator__text--condensed'),
onClick: () => setTextStyle(TextStyle.Condensed),
value: TextStyle.Condensed,
},
]}
moduleClassName={classNames('StoryCreator__tools__tool', {
'StoryCreator__tools__button--font-regular':
textStyle === TextStyle.Regular,
'StoryCreator__tools__button--font-bold':
textStyle === TextStyle.Bold,
'StoryCreator__tools__button--font-serif':
textStyle === TextStyle.Serif,
'StoryCreator__tools__button--font-script':
textStyle === TextStyle.Script,
'StoryCreator__tools__button--font-condensed':
textStyle === TextStyle.Condensed,
})}
theme={Theme.Dark}
value={textStyle}
/>
<button
aria-label={getBgButtonAriaLabel(i18n, textBackground)}
className={classNames('StoryCreator__tools__tool', {
'StoryCreator__tools__button--bg-none':
textBackground === TextBackground.None,
'StoryCreator__tools__button--bg':
textBackground === TextBackground.Background,
'StoryCreator__tools__button--bg-inverse':
textBackground === TextBackground.Inverse,
})}
onClick={() => {
if (textBackground === TextBackground.None) {
setTextBackground(TextBackground.Background);
} else if (textBackground === TextBackground.Background) {
setTextBackground(TextBackground.Inverse);
} else {
setTextBackground(TextBackground.None);
}
}}
type="button"
/>
<EmojiButton
className="StoryCreator__emoji-button"
i18n={i18n}
onPickEmoji={data => {
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}
/>
</div>
) : (
<div className="StoryCreator__toolbar--space" />
)}
<div className="StoryCreator__toolbar--buttons">
<Button
onClick={onTryClose}
theme={Theme.Dark}
variant={ButtonVariant.Secondary}
>
{i18n('icu:discard')}
</Button>
<div className="StoryCreator__controls">
<button
aria-label={i18n('icu:StoryCreator__story-bg')}
className={classNames({
StoryCreator__control: true,
'StoryCreator__control--bg': true,
'StoryCreator__control--bg--selected': isColorPickerShowing,
})}
onClick={() => setIsColorPickerShowing(!isColorPickerShowing)}
ref={setColorPickerPopperButtonRef}
style={{
background: getBackgroundColor(
getBackground(selectedBackground)
),
}}
type="button"
/>
{isColorPickerShowing && (
<div
className="StoryCreator__popper"
ref={setColorPickerPopperRef}
style={colorPickerPopper.styles.popper}
{...colorPickerPopper.attributes.popper}
>
<div
data-popper-arrow
className="StoryCreator__popper__arrow"
/>
{objectMap<BackgroundStyleType>(
BackgroundStyle,
(bg, backgroundValue) => (
<button
aria-label={i18n('icu:StoryCreator__story-bg')}
className={classNames({
StoryCreator__bg: true,
'StoryCreator__bg--selected':
selectedBackground === backgroundValue,
})}
key={String(bg)}
onClick={() => {
setSelectedBackground(backgroundValue);
setIsColorPickerShowing(false);
}}
type="button"
style={{
background: getBackgroundColor(
getBackground(backgroundValue)
),
}}
/>
)
)}
</div>
)}
<button
aria-label={i18n('icu:StoryCreator__control--text')}
className={classNames({
StoryCreator__control: true,
'StoryCreator__control--text': true,
'StoryCreator__control--selected': isEditingText,
})}
onClick={() => {
setIsEditingText(!isEditingText);
}}
type="button"
/>
<button
aria-label={i18n('icu:StoryCreator__control--link')}
className="StoryCreator__control StoryCreator__control--link"
onClick={() =>
setIsLinkPreviewInputShowing(!isLinkPreviewInputShowing)
}
ref={setLinkPreviewInputPopperButtonRef}
type="button"
/>
{isLinkPreviewInputShowing && (
<div
className={classNames(
'StoryCreator__popper StoryCreator__link-preview-input-popper',
themeClassName(Theme.Dark)
)}
ref={setLinkPreviewInputPopperRef}
style={linkPreviewInputPopper.styles.popper}
{...linkPreviewInputPopper.attributes.popper}
>
<div
data-popper-arrow
className="StoryCreator__popper__arrow"
/>
<Input
disableSpellcheck
i18n={i18n}
moduleClassName="StoryCreator__link-preview-input"
onChange={setLinkPreviewInputValue}
placeholder={i18n(
'icu:StoryCreator__link-preview-placeholder'
)}
ref={el => el?.focus()}
value={linkPreviewInputValue}
/>
<div className="StoryCreator__link-preview-container">
{linkPreview ? (
<>
<div className="StoryCreator__link-preview-wrapper">
<StoryLinkPreview
{...linkPreview}
forceCompactMode
i18n={i18n}
/>
</div>
<Button
className="StoryCreator__link-preview-button"
onClick={() => {
setLinkPreviewApplied(LinkPreviewApplied.Manual);
setIsLinkPreviewInputShowing(false);
}}
theme={Theme.Dark}
variant={ButtonVariant.Primary}
>
{i18n('icu:StoryCreator__add-link')}
</Button>
</>
) : (
<div className="StoryCreator__link-preview-empty">
<div className="StoryCreator__link-preview-empty__icon" />
{i18n('icu:StoryCreator__link-preview-empty')}
</div>
)}
</div>
</div>
)}
</div>
<Button
disabled={!hasChanges || isSending}
onClick={() => onDone(textAttachment)}
theme={Theme.Dark}
variant={ButtonVariant.Primary}
>
{isSending ? (
<Spinner svgSize="small" />
) : (
i18n('icu:StoryCreator__next')
)}
</Button>
</div>
</div>
{showConfirmDiscardModal && (
<ConfirmDiscardDialog
i18n={i18n}
onClose={() => setShowConfirmDiscardModal(false)}
onDiscard={onClose}
/>
)}
</div>
</FocusTrap>
);
}