Create text stories

This commit is contained in:
Josh Perez 2022-06-16 20:48:57 -04:00 committed by GitHub
parent 973b2264fe
commit d970d427f8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 2433 additions and 1106 deletions

View file

@ -41,7 +41,7 @@ import { AudioCapture } from './conversation/AudioCapture';
import { CompositionUpload } from './CompositionUpload';
import type { ConversationType } from '../state/ducks/conversations';
import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { LinkPreviewWithDomain } from '../types/LinkPreview';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions';
import { MediaQualitySelector } from './MediaQualitySelector';
@ -102,7 +102,7 @@ export type OwnProps = Readonly<{
isSMSOnly?: boolean;
left?: boolean;
linkPreviewLoading: boolean;
linkPreviewResult?: LinkPreviewWithDomain;
linkPreviewResult?: LinkPreviewType;
messageRequestsEnabled?: boolean;
onClearAttachments(): unknown;
onClickQuotedMessage(): unknown;
@ -631,10 +631,10 @@ export const CompositionArea = ({
/>
</div>
)}
{linkPreviewLoading && (
{linkPreviewLoading && linkPreviewResult && (
<div className="preview-wrapper">
<StagedLinkPreview
{...(linkPreviewResult || {})}
{...linkPreviewResult}
i18n={i18n}
onClose={onCloseLinkPreview}
/>

View file

@ -1,7 +1,7 @@
// Copyright 2018-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { KeyboardEvent } from 'react';
import type { CSSProperties, KeyboardEvent } from 'react';
import type { Options } from '@popperjs/core';
import FocusTrap from 'focus-trap-react';
import React, { useEffect, useState } from 'react';
@ -35,6 +35,7 @@ export type ContextMenuPropsType<T> = {
export type PropsType<T> = {
readonly buttonClassName?: string;
readonly buttonStyle?: CSSProperties;
readonly i18n: LocalizerType;
} & Pick<
ContextMenuPropsType<T>,
@ -139,6 +140,7 @@ export function ContextMenuPopper<T>({
export function ContextMenu<T>({
buttonClassName,
buttonStyle,
i18n,
menuOptions,
popperOptions,
@ -208,24 +210,27 @@ export function ContextMenu<T>({
onClick={handleClick}
onKeyDown={handleKeyDown}
ref={setReferenceElement}
style={buttonStyle}
type="button"
/>
<FocusTrap
focusTrapOptions={{
allowOutsideClick: true,
}}
>
<ContextMenuPopper
focusedIndex={focusedIndex}
isMenuShowing={menuShowing}
menuOptions={menuOptions}
onClose={() => setMenuShowing(false)}
popperOptions={popperOptions}
referenceElement={referenceElement}
title={title}
value={value}
/>
</FocusTrap>
{menuShowing && (
<FocusTrap
focusTrapOptions={{
allowOutsideClick: true,
}}
>
<ContextMenuPopper
focusedIndex={focusedIndex}
isMenuShowing={menuShowing}
menuOptions={menuOptions}
onClose={() => setMenuShowing(false)}
popperOptions={popperOptions}
referenceElement={referenceElement}
title={title}
value={value}
/>
</FocusTrap>
)}
</div>
);
}

View file

@ -322,13 +322,14 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
{linkPreview ? (
<div className="module-ForwardMessageModal--link-preview">
<StagedLinkPreview
date={linkPreview.date || null}
date={linkPreview.date}
description={linkPreview.description || ''}
domain={linkPreview.url}
i18n={i18n}
image={linkPreview.image}
onClose={() => removeLinkPreview()}
title={linkPreview.title}
url={linkPreview.url}
/>
</div>
) : null}

View file

@ -564,7 +564,7 @@ export const MediaEditor = ({
<Slider
handleStyle={{ backgroundColor: getHSL(sliderValue) }}
label={i18n('CustomColorEditor__hue')}
moduleClassName="MediaEditor__hue-slider MediaEditor__tools__tool"
moduleClassName="HueSlider MediaEditor__tools__tool"
onChange={setSliderValue}
value={sliderValue}
/>
@ -623,7 +623,7 @@ export const MediaEditor = ({
<Slider
handleStyle={{ backgroundColor: getHSL(sliderValue) }}
label={i18n('CustomColorEditor__hue')}
moduleClassName="MediaEditor__tools__tool MediaEditor__hue-slider"
moduleClassName="HueSlider MediaEditor__tools__tool"
onChange={setSliderValue}
value={sliderValue}
/>

View file

@ -1,6 +1,7 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import React from 'react';
import { v4 as uuid } from 'uuid';
import { action } from '@storybook/addon-actions';
@ -22,7 +23,8 @@ const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/Stories',
};
component: Stories,
} as Meta;
function createStory({
attachment,
@ -83,6 +85,7 @@ const getDefaultProps = (): PropsType => ({
i18n,
preferredWidthFromStorage: 380,
queueStoryDownload: action('queueStoryDownload'),
renderStoryCreator: () => <div />,
renderStoryViewer: () => <div />,
showConversation: action('showConversation'),
stories: [
@ -127,7 +130,13 @@ const getDefaultProps = (): PropsType => ({
toggleStoriesView: action('toggleStoriesView'),
});
export const Blank = (): JSX.Element => (
<Stories {...getDefaultProps()} stories={[]} />
);
export const Many = (): JSX.Element => <Stories {...getDefaultProps()} />;
const Template: Story<PropsType> = args => <Stories {...args} />;
export const Blank = Template.bind({});
Blank.args = {
...getDefaultProps(),
stories: [],
};
export const Many = Template.bind({});
Many.args = getDefaultProps();

View file

@ -6,6 +6,7 @@ import React, { useCallback, useState } from 'react';
import classNames from 'classnames';
import type { ConversationStoryType } from './StoryListItem';
import type { LocalizerType } from '../types/Util';
import type { PropsType as SmartStoryCreatorPropsType } from '../state/smart/StoryCreator';
import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer';
import type { ShowConversationType } from '../state/ducks/conversations';
import { StoriesPane } from './StoriesPane';
@ -18,6 +19,7 @@ export type PropsType = {
i18n: LocalizerType;
preferredWidthFromStorage: number;
queueStoryDownload: (storyId: string) => unknown;
renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element;
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
showConversation: ShowConversationType;
stories: Array<ConversationStoryType>;
@ -30,6 +32,7 @@ export const Stories = ({
i18n,
preferredWidthFromStorage,
queueStoryDownload,
renderStoryCreator,
renderStoryViewer,
showConversation,
stories,
@ -96,8 +99,14 @@ export const Stories = ({
setConversationIdToView(prevStory.conversationId);
}, [conversationIdToView, stories]);
const [isShowingStoryCreator, setIsShowingStoryCreator] = useState(false);
return (
<div className={classNames('Stories', themeClassName(Theme.Dark))}>
{isShowingStoryCreator &&
renderStoryCreator({
onClose: () => setIsShowingStoryCreator(false),
})}
{conversationIdToView &&
renderStoryViewer({
conversationId: conversationIdToView,
@ -110,6 +119,7 @@ export const Stories = ({
<StoriesPane
hiddenStories={hiddenStories}
i18n={i18n}
onAddStory={() => setIsShowingStoryCreator(true)}
onStoryClicked={clickedIdToView => {
const storyIndex = stories.findIndex(
x => x.conversationId === clickedIdToView

View file

@ -4,12 +4,13 @@
import Fuse from 'fuse.js';
import React, { useEffect, useState } from 'react';
import classNames from 'classnames';
import { isNotNil } from '../util/isNotNil';
import type { ConversationStoryType, StoryViewType } from './StoryListItem';
import type { LocalizerType } from '../types/Util';
import type { ShowConversationType } from '../state/ducks/conversations';
import { SearchInput } from './SearchInput';
import { StoryListItem } from './StoryListItem';
import { isNotNil } from '../util/isNotNil';
const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationStoryType> = {
getFn: (obj, path) => {
@ -53,6 +54,7 @@ function getNewestStory(story: ConversationStoryType): StoryViewType {
export type PropsType = {
hiddenStories: Array<ConversationStoryType>;
i18n: LocalizerType;
onAddStory: () => unknown;
onStoryClicked: (conversationId: string) => unknown;
queueStoryDownload: (storyId: string) => unknown;
showConversation: ShowConversationType;
@ -64,6 +66,7 @@ export type PropsType = {
export const StoriesPane = ({
hiddenStories,
i18n,
onAddStory,
onStoryClicked,
queueStoryDownload,
showConversation,
@ -97,6 +100,12 @@ export const StoriesPane = ({
<div className="Stories__pane__header--title">
{i18n('Stories__title')}
</div>
<button
aria-label={i18n('Stories__add')}
className="Stories__pane__header--camera"
onClick={onAddStory}
type="button"
/>
</div>
<SearchInput
i18n={i18n}

View file

@ -0,0 +1,50 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './StoryCreator';
import enMessages from '../../_locales/en/messages.json';
import { StoryCreator } from './StoryCreator';
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
import { setupI18n } from '../util/setupI18n';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/StoryCreator',
component: StoryCreator,
} as Meta;
const getDefaultProps = (): PropsType => ({
debouncedMaybeGrabLinkPreview: action('debouncedMaybeGrabLinkPreview'),
i18n,
onClose: action('onClose'),
onNext: action('onNext'),
});
const Template: Story<PropsType> = args => <StoryCreator {...args} />;
export const Default = Template.bind({});
Default.args = getDefaultProps();
Default.story = {
name: 'w/o Link Preview available',
};
export const LinkPreview = Template.bind({});
LinkPreview.args = {
...getDefaultProps(),
linkPreview: {
domain: 'www.catsandkittens.lolcats',
image: fakeAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
}),
title: 'Cats & Kittens LOL',
url: 'https://www.catsandkittens.lolcats/kittens/page/1',
},
};
LinkPreview.story = {
name: 'with Link Preview ready to be applied',
};

View file

@ -0,0 +1,485 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import FocusTrap from 'focus-trap-react';
import React, { useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { get, has } from 'lodash';
import { usePopper } from 'react-popper';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import type { LocalizerType } from '../types/Util';
import type { TextAttachmentType } from '../types/Attachment';
import { Button, ButtonVariant } from './Button';
import { ContextMenu } from './ContextMenu';
import { LinkPreviewSourceType, findLinks } from '../types/LinkPreview';
import { Input } from './Input';
import { Slider } from './Slider';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
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 { objectMap } from '../util/objectMap';
export type PropsType = {
debouncedMaybeGrabLinkPreview: (
message: string,
source: LinkPreviewSourceType
) => unknown;
i18n: LocalizerType;
linkPreview?: LinkPreviewType;
onClose: () => unknown;
onNext: () => unknown;
};
enum TextStyle {
Default,
Regular,
Bold,
Serif,
Script,
Condensed,
}
enum TextBackground {
None,
Background,
Inverse,
}
const BackgroundStyle = {
BG1099: { angle: 191, endColor: 4282529679, startColor: 4294260804 },
BG1098: { startColor: 4293938406, endColor: 4279119837, angle: 192 },
BG1031: { startColor: 4294950980, endColor: 4294859832, angle: 175 },
BG1101: { startColor: 4278227945, endColor: 4286632135, angle: 180 },
BG1100: { startColor: 4284861868, endColor: 4278884698, angle: 180 },
BG1070: { color: 4294951251 },
BG1080: { color: 4291607859 },
BG1079: { color: 4286869806 },
BG1083: { color: 4278825851 },
BG1095: { color: 4287335417 },
BG1088: { color: 4283519478 },
BG1077: { color: 4294405742 },
BG1094: { color: 4291315265 },
BG1097: { color: 4291216549 },
BG1074: { color: 4288976277 },
BG1092: { color: 4280887593 },
};
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 },
};
}
export const StoryCreator = ({
debouncedMaybeGrabLinkPreview,
i18n,
linkPreview,
onClose,
onNext,
}: PropsType): JSX.Element => {
const [isEditingText, setIsEditingText] = useState(false);
const [selectedBackground, setSelectedBackground] =
useState<BackgroundStyleType>(BackgroundStyle.BG1099);
const [textStyle, setTextStyle] = useState<TextStyle>(TextStyle.Regular);
const [textBackground, setTextBackground] = useState<TextBackground>(
TextBackground.None
);
const [sliderValue, setSliderValue] = useState<number>(0);
const [text, setText] = useState<string>('');
const textEditorRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (isEditingText) {
textEditorRef.current?.focus();
} else {
textEditorRef.current?.blur();
}
}, [isEditingText]);
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 [hasLinkPreviewApplied, setHasLinkPreviewApplied] = useState(false);
const [linkPreviewInputValue, setLinkPreviewInputValue] = useState('');
useEffect(() => {
if (!linkPreviewInputValue) {
return;
}
debouncedMaybeGrabLinkPreview(
linkPreviewInputValue,
LinkPreviewSourceType.StoryCreator
);
}, [debouncedMaybeGrabLinkPreview, linkPreviewInputValue]);
useEffect(() => {
if (!text) {
return;
}
debouncedMaybeGrabLinkPreview(text, LinkPreviewSourceType.StoryCreator);
}, [debouncedMaybeGrabLinkPreview, text]);
useEffect(() => {
if (!linkPreview || !text) {
return;
}
const links = findLinks(text);
const shouldApplyLinkPreview = links.includes(linkPreview.url);
setHasLinkPreviewApplied(shouldApplyLinkPreview);
}, [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 handleOutsideClick = (event: MouseEvent) => {
if (!colorPickerPopperButtonRef?.contains(event.target as Node)) {
setIsColorPickerShowing(false);
event.stopPropagation();
event.preventDefault();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsColorPickerShowing(false);
event.preventDefault();
event.stopPropagation();
}
};
document.addEventListener('click', handleOutsideClick);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('click', handleOutsideClick);
document.removeEventListener('keydown', handleEscape);
};
}, [isColorPickerShowing, 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;
}
return (
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
<div className="StoryCreator">
<div className="StoryCreator__container">
<TextAttachment
i18n={i18n}
isEditingText={isEditingText}
onChange={setText}
textAttachment={{
...getBackground(selectedBackground),
text,
textStyle,
textForegroundColor,
textBackgroundColor,
preview: hasLinkPreviewApplied ? linkPreview : undefined,
}}
/>
</div>
<div className="StoryCreator__toolbar">
{isEditingText ? (
<div className="StoryCreator__tools">
<Slider
handleStyle={{ backgroundColor: getRGBA(sliderValue) }}
label={i18n('CustomColorEditor__hue')}
moduleClassName="HueSlider StoryCreator__tools__tool"
onChange={setSliderValue}
value={sliderValue}
/>
<ContextMenu
buttonClassName={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,
})}
i18n={i18n}
menuOptions={[
{
icon: 'StoryCreator__icon--font-regular',
label: i18n('StoryCreator__text--regular'),
onClick: () => setTextStyle(TextStyle.Regular),
value: TextStyle.Regular,
},
{
icon: 'StoryCreator__icon--font-bold',
label: i18n('StoryCreator__text--bold'),
onClick: () => setTextStyle(TextStyle.Bold),
value: TextStyle.Bold,
},
{
icon: 'StoryCreator__icon--font-serif',
label: i18n('StoryCreator__text--serif'),
onClick: () => setTextStyle(TextStyle.Serif),
value: TextStyle.Serif,
},
{
icon: 'StoryCreator__icon--font-script',
label: i18n('StoryCreator__text--script'),
onClick: () => setTextStyle(TextStyle.Script),
value: TextStyle.Script,
},
{
icon: 'StoryCreator__icon--font-condensed',
label: i18n('StoryCreator__text--condensed'),
onClick: () => setTextStyle(TextStyle.Condensed),
value: TextStyle.Condensed,
},
]}
theme={Theme.Dark}
value={textStyle}
/>
<button
aria-label={i18n('StoryCreator__text-bg')}
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"
/>
</div>
) : (
<div className="StoryCreator__toolbar--space" />
)}
<div className="StoryCreator__toolbar--buttons">
<Button
onClick={onClose}
theme={Theme.Dark}
variant={ButtonVariant.Secondary}
>
{i18n('discard')}
</Button>
<div className="StoryCreator__controls">
<button
aria-label={i18n('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('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('StoryCreator__control--draw')}
className={classNames({
StoryCreator__control: true,
'StoryCreator__control--text': true,
'StoryCreator__control--selected': isEditingText,
})}
onClick={() => {
setIsEditingText(!isEditingText);
}}
type="button"
/>
<button
aria-label={i18n('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('StoryCreator__link-preview-placeholder')}
ref={el => el?.focus()}
value={linkPreviewInputValue}
/>
<div className="StoryCreator__link-preview-container">
{linkPreview ? (
<>
<StagedLinkPreview
domain={linkPreview.domain}
i18n={i18n}
image={linkPreview.image}
moduleClassName="StoryCreator__link-preview"
title={linkPreview.title}
url={linkPreview.url}
/>
<Button
className="StoryCreator__link-preview-button"
onClick={() => {
setHasLinkPreviewApplied(true);
setIsLinkPreviewInputShowing(false);
}}
theme={Theme.Dark}
variant={ButtonVariant.Primary}
>
{i18n('StoryCreator__add-link')}
</Button>
</>
) : (
<div className="StoryCreator__link-preview-empty">
<div className="StoryCreator__link-preview-empty__icon" />
{i18n('StoryCreator__link-preview-empty')}
</div>
)}
</div>
</div>
)}
</div>
<Button
onClick={onNext}
theme={Theme.Dark}
variant={ButtonVariant.Primary}
>
{i18n('StoryCreator__next')}
</Button>
</div>
</div>
</div>
</FocusTrap>
);
};

View file

@ -2,18 +2,21 @@
// SPDX-License-Identifier: AGPL-3.0-only
import Measure from 'react-measure';
import React, { useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import classNames from 'classnames';
import type { LocalizerType, RenderTextCallbackType } from '../types/Util';
import type { TextAttachmentType } from '../types/Attachment';
import { AddNewLines } from './conversation/AddNewLines';
import { Emojify } from './conversation/Emojify';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import { TextAttachmentStyleType } from '../types/Attachment';
import { count } from '../util/grapheme';
import { getDomain } from '../types/LinkPreview';
import { getFontNameByTextScript } from '../util/getFontNameByTextScript';
import {
COLOR_WHITE_INT,
getHexFromNumber,
getBackgroundColor,
} from '../util/getStoryBackground';
@ -27,7 +30,6 @@ const renderNewLines: RenderTextCallbackType = ({
const CHAR_LIMIT_TEXT_LARGE = 50;
const CHAR_LIMIT_TEXT_MEDIUM = 200;
const COLOR_WHITE_INT = 4294704123;
const FONT_SIZE_LARGE = 64;
const FONT_SIZE_MEDIUM = 42;
const FONT_SIZE_SMALL = 32;
@ -40,7 +42,9 @@ enum TextSize {
export type PropsType = {
i18n: LocalizerType;
isEditingText?: boolean;
isThumbnail?: boolean;
onChange?: (text: string) => unknown;
textAttachment: TextAttachmentType;
};
@ -84,9 +88,24 @@ function getFont(
return `${fontWeight}${fontSize}pt ${fontName}`;
}
function getTextStyles(
textContent: string,
textForegroundColor?: number | null,
textStyle?: TextAttachmentStyleType | null,
i18n?: LocalizerType
): { color: string; font: string; textAlign: 'left' | 'center' } {
return {
color: getHexFromNumber(textForegroundColor || COLOR_WHITE_INT),
font: getFont(textContent, getTextSize(textContent), textStyle, i18n),
textAlign: getTextSize(textContent) === TextSize.Small ? 'left' : 'center',
};
}
export const TextAttachment = ({
i18n,
isEditingText,
isThumbnail,
onChange,
textAttachment,
}: PropsType): JSX.Element | null => {
const linkPreview = useRef<HTMLDivElement | null>(null);
@ -94,6 +113,20 @@ export const TextAttachment = ({
number | undefined
>();
const textContent = textAttachment.text || '';
const textEditorRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const node = textEditorRef.current;
if (!node) {
return;
}
node.focus();
node.setSelectionRange(node.value.length, node.value.length);
}, [isEditingText]);
return (
<Measure bounds>
{({ contentRect, measureRef }) => (
@ -119,62 +152,72 @@ export const TextAttachment = ({
transform: `scale(${(contentRect.bounds?.height || 1) / 1280})`,
}}
>
{textAttachment.text && (
{(textContent || onChange) && (
<div
className="TextAttachment__text"
style={{
backgroundColor: textAttachment.textBackgroundColor
? getHexFromNumber(textAttachment.textBackgroundColor)
: 'none',
color: getHexFromNumber(
textAttachment.textForegroundColor || COLOR_WHITE_INT
),
font: getFont(
textAttachment.text,
getTextSize(textAttachment.text),
textAttachment.textStyle,
i18n
),
textAlign:
getTextSize(textAttachment.text) === TextSize.Small
? 'left'
: 'center',
: 'transparent',
}}
>
<div className="TextAttachment__text__container">
<Emojify
text={textAttachment.text}
renderNonEmoji={renderNewLines}
{onChange ? (
<TextareaAutosize
className="TextAttachment__text__container TextAttachment__text__textarea"
disabled={!isEditingText}
onChange={ev => onChange(ev.currentTarget.value)}
placeholder={i18n('TextAttachment__placeholder')}
ref={textEditorRef}
style={getTextStyles(
textContent,
textAttachment.textForegroundColor,
textAttachment.textStyle,
i18n
)}
value={textContent}
/>
</div>
) : (
<div
className="TextAttachment__text__container"
style={getTextStyles(
textContent,
textAttachment.textForegroundColor,
textAttachment.textStyle,
i18n
)}
>
<Emojify
text={textContent}
renderNonEmoji={renderNewLines}
/>
</div>
)}
</div>
)}
{textAttachment.preview && (
{textAttachment.preview && textAttachment.preview.url && (
<>
{linkPreviewOffsetTop &&
!isThumbnail &&
textAttachment.preview.url && (
<a
className="TextAttachment__preview__tooltip"
href={textAttachment.preview.url}
rel="noreferrer"
style={{
top: linkPreviewOffsetTop - 150,
}}
target="_blank"
>
<div>
<div>{i18n('TextAttachment__preview__link')}</div>
<div className="TextAttachment__preview__tooltip__url">
{textAttachment.preview.url}
</div>
{linkPreviewOffsetTop && !isThumbnail && (
<a
className="TextAttachment__preview__tooltip"
href={textAttachment.preview.url}
rel="noreferrer"
style={{
top: linkPreviewOffsetTop - 150,
}}
target="_blank"
>
<div>
<div>{i18n('TextAttachment__preview__link')}</div>
<div className="TextAttachment__preview__tooltip__url">
{textAttachment.preview.url}
</div>
<div className="TextAttachment__preview__tooltip__arrow" />
</a>
)}
</div>
<div className="TextAttachment__preview__tooltip__arrow" />
</a>
)}
<div
className={classNames('TextAttachment__preview', {
'TextAttachment__preview--large': Boolean(
className={classNames('TextAttachment__preview-container', {
'TextAttachment__preview-container--large': Boolean(
textAttachment.preview.title
),
})}
@ -186,17 +229,14 @@ export const TextAttachment = ({
setLinkPreviewOffsetTop(linkPreview?.current?.offsetTop)
}
>
<div className="TextAttachment__preview__image" />
<div className="TextAttachment__preview__title">
{textAttachment.preview.title && (
<div className="TextAttachment__preview__title__container">
{textAttachment.preview.title}
</div>
)}
<div className="TextAttachment__preview__url">
{getDomain(String(textAttachment.preview.url))}
</div>
</div>
<StagedLinkPreview
domain={getDomain(String(textAttachment.preview.url))}
i18n={i18n}
image={textAttachment.preview.image}
moduleClassName="TextAttachment__preview"
title={textAttachment.preview.title || undefined}
url={textAttachment.preview.url}
/>
</div>
</>
)}

View file

@ -1192,7 +1192,10 @@ export class Message extends React.PureComponent<Props, State> {
/>
) : null}
<div className="module-message__link-preview__content">
{first.image && previewHasImage && !isFullSizeImage ? (
{first.image &&
first.domain &&
previewHasImage &&
!isFullSizeImage ? (
<div className="module-message__link-preview__icon_container">
<Image
noBorder

View file

@ -1,16 +1,16 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import * as React from 'react';
import { date, text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import type { AttachmentType } from '../../types/Attachment';
import { stringToMIMEType } from '../../types/MIME';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import type { Props } from './StagedLinkPreview';
import enMessages from '../../../_locales/en/messages.json';
import { StagedLinkPreview } from './StagedLinkPreview';
import { fakeAttachment } from '../../test-both/helpers/fakeAttachment';
import { setupI18n } from '../../util/setupI18n';
import { IMAGE_JPEG } from '../../types/MIME';
const LONG_TITLE =
"This is a super-sweet site. And it's got some really amazing content in store for you if you just click that link. Can you click that link for me?";
@ -21,150 +21,109 @@ const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/Conversation/StagedLinkPreview',
};
component: StagedLinkPreview,
} as Meta;
const createAttachment = (
props: Partial<AttachmentType> = {}
): AttachmentType => ({
contentType: stringToMIMEType(
text('attachment contentType', props.contentType || '')
),
fileName: text('attachment fileName', props.fileName || ''),
url: text('attachment url', props.url || ''),
size: 24325,
});
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
title: text(
'title',
typeof overrideProps.title === 'string'
? overrideProps.title
: 'This is a super-sweet site'
),
description: text(
'description',
typeof overrideProps.description === 'string'
? overrideProps.description
: 'This is a description'
),
date: date('date', new Date(overrideProps.date || 0)),
domain: text('domain', overrideProps.domain || 'signal.org'),
image: overrideProps.image,
const getDefaultProps = (): Props => ({
date: Date.now(),
description: 'This is a description',
domain: 'signal.org',
i18n,
onClose: action('onClose'),
title: 'This is a super-sweet site',
url: 'https://www.signal.org',
});
export const Loading = (): JSX.Element => {
const props = createProps({ domain: '' });
const Template: Story<Props> = args => <StagedLinkPreview {...args} />;
return <StagedLinkPreview {...props} />;
export const Loading = Template.bind({});
Loading.args = {
...getDefaultProps(),
domain: '',
};
export const NoImage = (): JSX.Element => {
return <StagedLinkPreview {...createProps()} />;
export const NoImage = Template.bind({});
export const Image = Template.bind({});
Image.args = {
...getDefaultProps(),
image: fakeAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: IMAGE_JPEG,
}),
};
export const Image = (): JSX.Element => {
const props = createProps({
image: createAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: stringToMIMEType('image/jpeg'),
}),
});
return <StagedLinkPreview {...props} />;
export const ImageNoTitleOrDescription = Template.bind({});
ImageNoTitleOrDescription.args = {
...getDefaultProps(),
title: '',
description: '',
domain: 'instagram.com',
image: fakeAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: IMAGE_JPEG,
}),
};
export const ImageNoTitleOrDescription = (): JSX.Element => {
const props = createProps({
title: '',
description: '',
domain: 'instagram.com',
image: createAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: stringToMIMEType('image/jpeg'),
}),
});
return <StagedLinkPreview {...props} />;
};
ImageNoTitleOrDescription.story = {
name: 'Image, No Title Or Description',
};
export const NoImageLongTitleWithDescription = (): JSX.Element => {
const props = createProps({
title: LONG_TITLE,
});
return <StagedLinkPreview {...props} />;
export const NoImageLongTitleWithDescription = Template.bind({});
NoImageLongTitleWithDescription.args = {
...getDefaultProps(),
title: LONG_TITLE,
};
NoImageLongTitleWithDescription.story = {
name: 'No Image, Long Title With Description',
};
export const NoImageLongTitleWithoutDescription = (): JSX.Element => {
const props = createProps({
title: LONG_TITLE,
description: '',
});
return <StagedLinkPreview {...props} />;
export const NoImageLongTitleWithoutDescription = Template.bind({});
NoImageLongTitleWithoutDescription.args = {
...getDefaultProps(),
title: LONG_TITLE,
description: '',
};
NoImageLongTitleWithoutDescription.story = {
name: 'No Image, Long Title Without Description',
};
export const ImageLongTitleWithoutDescription = (): JSX.Element => {
const props = createProps({
title: LONG_TITLE,
image: createAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: stringToMIMEType('image/jpeg'),
}),
});
return <StagedLinkPreview {...props} />;
export const ImageLongTitleWithoutDescription = Template.bind({});
ImageLongTitleWithoutDescription.args = {
...getDefaultProps(),
title: LONG_TITLE,
image: fakeAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: IMAGE_JPEG,
}),
};
ImageLongTitleWithoutDescription.story = {
name: 'Image, Long Title Without Description',
};
export const ImageLongTitleAndDescription = (): JSX.Element => {
const props = createProps({
title: LONG_TITLE,
description: LONG_DESCRIPTION,
image: createAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: stringToMIMEType('image/jpeg'),
}),
});
return <StagedLinkPreview {...props} />;
export const ImageLongTitleAndDescription = Template.bind({});
ImageLongTitleAndDescription.args = {
...getDefaultProps(),
title: LONG_TITLE,
description: LONG_DESCRIPTION,
image: fakeAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: IMAGE_JPEG,
}),
};
ImageLongTitleAndDescription.story = {
name: 'Image, Long Title And Description',
};
export const EverythingImageTitleDescriptionAndDate = (): JSX.Element => {
const props = createProps({
title: LONG_TITLE,
description: LONG_DESCRIPTION,
date: Date.now(),
image: createAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: stringToMIMEType('image/jpeg'),
}),
});
return <StagedLinkPreview {...props} />;
export const EverythingImageTitleDescriptionAndDate = Template.bind({});
EverythingImageTitleDescriptionAndDate.args = {
...getDefaultProps(),
title: LONG_TITLE,
description: LONG_DESCRIPTION,
image: fakeAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: IMAGE_JPEG,
}),
};
EverythingImageTitleDescriptionAndDate.story = {
name: 'Everything: image, title, description, and date',
};

View file

@ -8,84 +8,86 @@ import { unescape } from 'lodash';
import { CurveType, Image } from './Image';
import { LinkPreviewDate } from './LinkPreviewDate';
import type { AttachmentType } from '../../types/Attachment';
import { isImageAttachment } from '../../types/Attachment';
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import type { LocalizerType } from '../../types/Util';
import { getClassNamesFor } from '../../util/getClassNamesFor';
import { isImageAttachment } from '../../types/Attachment';
export type Props = {
title?: string;
description?: null | string;
date?: null | number;
domain?: string;
image?: AttachmentType;
export type Props = LinkPreviewType & {
i18n: LocalizerType;
moduleClassName?: string;
onClose?: () => void;
};
export const StagedLinkPreview: React.FC<Props> = ({
onClose,
i18n,
title,
description,
image,
date,
description,
domain,
i18n,
image,
moduleClassName,
onClose,
title,
}: Props) => {
const isImage = isImageAttachment(image);
const isLoaded = Boolean(domain);
const getClassName = getClassNamesFor(
'module-staged-link-preview',
moduleClassName
);
return (
<div
className={classNames(
'module-staged-link-preview',
!isLoaded ? 'module-staged-link-preview--is-loading' : null
getClassName(''),
!isLoaded ? getClassName('--is-loading') : null
)}
>
{!isLoaded ? (
<div className="module-staged-link-preview__loading">
<div className={getClassName('__loading')}>
{i18n('loadingPreview')}
</div>
) : null}
{isLoaded && image && isImage && domain ? (
<div className="module-staged-link-preview__icon-container">
<div className={getClassName('__icon-container')}>
<Image
alt={i18n('stagedPreviewThumbnail', [domain])}
attachment={image}
curveBottomLeft={CurveType.Tiny}
curveBottomRight={CurveType.Tiny}
curveTopRight={CurveType.Tiny}
curveTopLeft={CurveType.Tiny}
curveTopRight={CurveType.Tiny}
height={72}
width={72}
url={image.url}
attachment={image}
i18n={i18n}
url={image.url}
width={72}
/>
</div>
) : null}
{isLoaded && !image && <div className={getClassName('__no-image')} />}
{isLoaded ? (
<div className="module-staged-link-preview__content">
<div className="module-staged-link-preview__title">{title}</div>
<div className={getClassName('__content')}>
<div className={getClassName('__title')}>{title}</div>
{description && (
<div className="module-staged-link-preview__description">
<div className={getClassName('__description')}>
{unescape(description)}
</div>
)}
<div className="module-staged-link-preview__footer">
<div className="module-staged-link-preview__location">{domain}</div>
<LinkPreviewDate
date={date}
className="module-message__link-preview__date"
/>
<div className={getClassName('__footer')}>
<div className={getClassName('__location')}>{domain}</div>
<LinkPreviewDate date={date} className={getClassName('__date')} />
</div>
</div>
) : null}
<button
type="button"
className="module-staged-link-preview__close-button"
onClick={onClose}
aria-label={i18n('close')}
/>
{onClose && (
<button
aria-label={i18n('close')}
className={getClassName('__close-button')}
onClick={onClose}
type="button"
/>
)}
</div>
);
};