Improvements to the media editor
This commit is contained in:
parent
e8eb7638c4
commit
d0296ececa
61 changed files with 1124 additions and 969 deletions
|
@ -1,52 +0,0 @@
|
|||
// 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}
|
||||
getPreferredBadge={() => undefined}
|
||||
i18n={i18n}
|
||||
isFormattingEnabled
|
||||
isFormattingFlagEnabled
|
||||
isFormattingSpoilersFlagEnabled
|
||||
onPickEmoji={action('onPickEmoji')}
|
||||
onChange={action('onChange')}
|
||||
onTextTooLong={action('onTextTooLong')}
|
||||
onSetSkinTone={action('onSetSkinTone')}
|
||||
platform="darwin"
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
const Template: Story<Props> = args => (
|
||||
<AddCaptionModal {...args} theme={React.useContext(StorybookThemeContext)} />
|
||||
);
|
||||
|
||||
export const Modal = Template.bind({});
|
||||
Modal.args = {
|
||||
draftText: 'Some caption text',
|
||||
};
|
|
@ -1,95 +0,0 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React 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';
|
||||
import type { HydratedBodyRangesType } from '../types/BodyRange';
|
||||
import { isScrolled, isScrolledToBottom } from '../hooks/useSizeObserver';
|
||||
|
||||
export type Props = {
|
||||
i18n: LocalizerType;
|
||||
onClose: () => void;
|
||||
onSubmit: (
|
||||
text: string,
|
||||
bodyRanges: HydratedBodyRangesType | undefined
|
||||
) => void;
|
||||
draftText: string;
|
||||
draftBodyRanges: HydratedBodyRangesType | undefined;
|
||||
theme: ThemeType;
|
||||
RenderCompositionTextArea: (
|
||||
props: SmartCompositionTextAreaProps
|
||||
) => JSX.Element;
|
||||
};
|
||||
|
||||
export function AddCaptionModal({
|
||||
i18n,
|
||||
onClose,
|
||||
onSubmit,
|
||||
draftText,
|
||||
draftBodyRanges,
|
||||
RenderCompositionTextArea,
|
||||
theme,
|
||||
}: Props): JSX.Element {
|
||||
const [messageText, setMessageText] = React.useState('');
|
||||
const [bodyRanges, setBodyRanges] = React.useState<
|
||||
HydratedBodyRangesType | undefined
|
||||
>();
|
||||
|
||||
const [scrolled, setScrolled] = React.useState(false);
|
||||
// We don't know that this is true, but it most likely is
|
||||
const [scrolledToBottom, setScrolledToBottom] = 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) {
|
||||
setScrolled(isScrolled(scrollerEl));
|
||||
setScrolledToBottom(isScrolledToBottom(scrollerEl));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = React.useCallback(() => {
|
||||
onSubmit(messageText, bodyRanges);
|
||||
}, [bodyRanges, messageText, onSubmit]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
i18n={i18n}
|
||||
modalName="AddCaptionModal"
|
||||
hasXButton
|
||||
hasHeaderDivider={scrolled}
|
||||
hasFooterDivider={!scrolledToBottom}
|
||||
moduleClassName="AddCaptionModal"
|
||||
padded={false}
|
||||
title={i18n('icu:AddCaptionModal__title')}
|
||||
onClose={onClose}
|
||||
modalFooter={
|
||||
<Button onClick={handleSubmit}>
|
||||
{i18n('icu:AddCaptionModal__submit-button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<RenderCompositionTextArea
|
||||
maxLength={1500}
|
||||
whenToShowRemainingCount={1450}
|
||||
placeholder={i18n('icu:AddCaptionModal__placeholder')}
|
||||
onChange={(updatedMessageText, updatedBodyRanges) => {
|
||||
setMessageText(updatedMessageText);
|
||||
setBodyRanges(updatedBodyRanges);
|
||||
}}
|
||||
scrollerRef={scrollerRef}
|
||||
draftText={draftText}
|
||||
bodyRanges={draftBodyRanges}
|
||||
onSubmit={noop}
|
||||
onScroll={updateScrollState}
|
||||
theme={theme}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -34,6 +34,7 @@ export default {
|
|||
const useProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
addAttachment: action('addAttachment'),
|
||||
conversationId: '123',
|
||||
convertDraftBodyRangesIntoHydrated: () => undefined,
|
||||
discardEditMessage: action('discardEditMessage'),
|
||||
focusCounter: 0,
|
||||
sendCounter: 0,
|
||||
|
|
|
@ -5,7 +5,10 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|||
import classNames from 'classnames';
|
||||
import type { ReadonlyDeep } from 'type-fest';
|
||||
|
||||
import type { DraftBodyRanges } from '../types/BodyRange';
|
||||
import type {
|
||||
DraftBodyRanges,
|
||||
HydratedBodyRangesType,
|
||||
} from '../types/BodyRange';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { ErrorDialogAudioRecorderType } from '../types/AudioRecorder';
|
||||
import { RecordingState } from '../types/AudioRecorder';
|
||||
|
@ -85,6 +88,9 @@ export type OwnProps = Readonly<{
|
|||
conversationId: string,
|
||||
onRecordingComplete: (rec: InMemoryAttachmentDraftType) => unknown
|
||||
) => unknown;
|
||||
convertDraftBodyRangesIntoHydrated: (
|
||||
bodyRanges: DraftBodyRanges | undefined
|
||||
) => HydratedBodyRangesType | undefined;
|
||||
conversationId: string;
|
||||
discardEditMessage: (id: string) => unknown;
|
||||
draftEditMessage?: DraftEditMessageType;
|
||||
|
@ -221,6 +227,7 @@ export function CompositionArea({
|
|||
// Base props
|
||||
addAttachment,
|
||||
conversationId,
|
||||
convertDraftBodyRangesIntoHydrated,
|
||||
discardEditMessage,
|
||||
draftEditMessage,
|
||||
focusCounter,
|
||||
|
@ -853,12 +860,25 @@ export function CompositionArea({
|
|||
'url' in attachmentToEdit &&
|
||||
attachmentToEdit.url && (
|
||||
<MediaEditor
|
||||
draftBodyRanges={draftBodyRanges}
|
||||
draftText={draftText}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
i18n={i18n}
|
||||
imageSrc={attachmentToEdit.url}
|
||||
imageToBlurHash={imageToBlurHash}
|
||||
installedPacks={installedPacks}
|
||||
isFormattingEnabled={isFormattingEnabled}
|
||||
isFormattingFlagEnabled={isFormattingFlagEnabled}
|
||||
isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled}
|
||||
isSending={false}
|
||||
onClose={() => setAttachmentToEdit(undefined)}
|
||||
onDone={({ data, contentType, blurHash }) => {
|
||||
onDone={({
|
||||
caption,
|
||||
captionBodyRanges,
|
||||
data,
|
||||
contentType,
|
||||
blurHash,
|
||||
}) => {
|
||||
const newAttachment = {
|
||||
...attachmentToEdit,
|
||||
contentType,
|
||||
|
@ -869,9 +889,25 @@ export function CompositionArea({
|
|||
|
||||
addAttachment(conversationId, newAttachment);
|
||||
setAttachmentToEdit(undefined);
|
||||
onEditorStateChange?.({
|
||||
bodyRanges: captionBodyRanges ?? [],
|
||||
conversationId,
|
||||
messageText: caption ?? '',
|
||||
sendCounter,
|
||||
});
|
||||
|
||||
inputApiRef.current?.setContents(
|
||||
caption ?? '',
|
||||
convertDraftBodyRangesIntoHydrated(captionBodyRanges),
|
||||
true
|
||||
);
|
||||
}}
|
||||
installedPacks={installedPacks}
|
||||
onPickEmoji={onPickEmoji}
|
||||
onTextTooLong={onTextTooLong}
|
||||
platform={platform}
|
||||
recentStickers={recentStickers}
|
||||
skinTone={skinTone}
|
||||
sortedGroupMembers={sortedGroupMembers}
|
||||
/>
|
||||
)}
|
||||
<div className="CompositionArea__toggle-large">
|
||||
|
|
|
@ -26,7 +26,7 @@ import { BodyRange, collapseRangeTree, insertRange } from '../types/BodyRange';
|
|||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import { isAciString } from '../types/ServiceId';
|
||||
import { isAciString } from '../util/isAciString';
|
||||
import { MentionBlot } from '../quill/mentions/blot';
|
||||
import {
|
||||
matchEmojiImage,
|
||||
|
|
|
@ -1,79 +1,85 @@
|
|||
// Copyright 2021 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 './MediaEditor';
|
||||
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);
|
||||
|
||||
export default {
|
||||
title: 'Components/MediaEditor',
|
||||
};
|
||||
|
||||
const IMAGE_1 = '/fixtures/nathan-anderson-316188-unsplash.jpg';
|
||||
const IMAGE_2 = '/fixtures/tina-rolf-269345-unsplash.jpg';
|
||||
const IMAGE_3 = '/fixtures/kitten-4-112-112.jpg';
|
||||
const IMAGE_4 = '/fixtures/snow.jpg';
|
||||
|
||||
const getDefaultProps = (): PropsType => ({
|
||||
i18n,
|
||||
imageSrc: IMAGE_2,
|
||||
onClose: action('onClose'),
|
||||
onDone: action('onDone'),
|
||||
isSending: false,
|
||||
imageToBlurHash: async () => 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
|
||||
export default {
|
||||
title: 'Components/MediaEditor',
|
||||
component: MediaEditor,
|
||||
argTypes: {
|
||||
getPreferredBadge: { action: true },
|
||||
i18n: {
|
||||
defaultValue: i18n,
|
||||
},
|
||||
imageToBlurHash: { action: true },
|
||||
imageSrc: {
|
||||
defaultValue: IMAGE_2,
|
||||
},
|
||||
installedPacks: {
|
||||
defaultValue: installedPacks,
|
||||
},
|
||||
isFormattingEnabled: {
|
||||
defaultValue: true,
|
||||
},
|
||||
isFormattingFlagEnabled: {
|
||||
defaultValue: true,
|
||||
},
|
||||
isFormattingSpoilersFlagEnabled: {
|
||||
defaultValue: true,
|
||||
},
|
||||
isSending: {
|
||||
defaultValue: false,
|
||||
},
|
||||
onClose: { action: true },
|
||||
onDone: { action: true },
|
||||
onPickEmoji: { action: true },
|
||||
onTextTooLong: { action: true },
|
||||
platform: {
|
||||
defaultValue: 'darwin',
|
||||
},
|
||||
recentStickers: {
|
||||
defaultValue: [Stickers.wide, Stickers.tall, Stickers.abe],
|
||||
},
|
||||
skinTone: {
|
||||
defaultValue: 0,
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
// StickerButtonProps
|
||||
installedPacks,
|
||||
recentStickers: [Stickers.wide, Stickers.tall, Stickers.abe],
|
||||
});
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
const Template: Story<PropsType> = args => <MediaEditor {...args} />;
|
||||
|
||||
export function ExtraLarge(): JSX.Element {
|
||||
return <MediaEditor {...getDefaultProps()} />;
|
||||
}
|
||||
export const ExtraLarge = Template.bind({});
|
||||
|
||||
export function Large(): JSX.Element {
|
||||
return <MediaEditor {...getDefaultProps()} imageSrc={IMAGE_1} />;
|
||||
}
|
||||
export const Large = Template.bind({});
|
||||
Large.args = {
|
||||
imageSrc: IMAGE_1,
|
||||
};
|
||||
|
||||
export function Smol(): JSX.Element {
|
||||
return <MediaEditor {...getDefaultProps()} imageSrc={IMAGE_3} />;
|
||||
}
|
||||
export const Smol = Template.bind({});
|
||||
Smol.args = {
|
||||
imageSrc: IMAGE_3,
|
||||
};
|
||||
|
||||
export function Portrait(): JSX.Element {
|
||||
return <MediaEditor {...getDefaultProps()} imageSrc={IMAGE_4} />;
|
||||
}
|
||||
export const Portrait = Template.bind({});
|
||||
Portrait.args = {
|
||||
imageSrc: IMAGE_4,
|
||||
};
|
||||
|
||||
export function Sending(): JSX.Element {
|
||||
return <MediaEditor {...getDefaultProps()} isSending />;
|
||||
}
|
||||
|
||||
export function WithCaption(): JSX.Element {
|
||||
return (
|
||||
<MediaEditor
|
||||
{...getDefaultProps()}
|
||||
supportsCaption
|
||||
renderCompositionTextArea={props => (
|
||||
<CompositionTextArea
|
||||
{...props}
|
||||
getPreferredBadge={() => undefined}
|
||||
i18n={i18n}
|
||||
isFormattingEnabled
|
||||
isFormattingFlagEnabled
|
||||
isFormattingSpoilersFlagEnabled
|
||||
onPickEmoji={action('onPickEmoji')}
|
||||
onSetSkinTone={action('onSetSkinTone')}
|
||||
onTextTooLong={action('onTextTooLong')}
|
||||
platform="darwin"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export const Sending = Template.bind({});
|
||||
Sending.args = {
|
||||
isSending: true,
|
||||
};
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -17,6 +17,7 @@ import type { PropsType as SendStoryModalPropsType } from './SendStoryModal';
|
|||
import type { StoryDistributionIdString } from '../types/StoryDistributionId';
|
||||
import type { imageToBlurHash } from '../util/imageToBlurHash';
|
||||
import type { PropsType as TextStoryCreatorPropsType } from './TextStoryCreator';
|
||||
import type { PropsType as MediaEditorPropsType } from './MediaEditor';
|
||||
|
||||
import { TEXT_ATTACHMENT } from '../types/MIME';
|
||||
import { isVideoAttachment } from '../types/Attachment';
|
||||
|
@ -24,7 +25,6 @@ import { SendStoryModal } from './SendStoryModal';
|
|||
|
||||
import { MediaEditor } from './MediaEditor';
|
||||
import { TextStoryCreator } from './TextStoryCreator';
|
||||
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
|
||||
import type { DraftBodyRanges } from '../types/BodyRange';
|
||||
|
||||
function usePortalElement(testid: string): HTMLDivElement | null {
|
||||
|
@ -63,9 +63,6 @@ export type PropsType = {
|
|||
processAttachment: (
|
||||
file: File
|
||||
) => Promise<void | InMemoryAttachmentDraftType>;
|
||||
renderCompositionTextArea: (
|
||||
props: SmartCompositionTextAreaProps
|
||||
) => JSX.Element;
|
||||
sendStoryModalOpenStateChanged: (isOpen: boolean) => unknown;
|
||||
theme: ThemeType;
|
||||
} & Pick<StickerButtonProps, 'installedPacks' | 'recentStickers'> &
|
||||
|
@ -96,6 +93,15 @@ export type PropsType = {
|
|||
Pick<
|
||||
TextStoryCreatorPropsType,
|
||||
'onUseEmoji' | 'skinTone' | 'onSetSkinTone' | 'recentEmojis'
|
||||
> &
|
||||
Pick<
|
||||
MediaEditorPropsType,
|
||||
| 'isFormattingEnabled'
|
||||
| 'isFormattingFlagEnabled'
|
||||
| 'isFormattingSpoilersFlagEnabled'
|
||||
| 'onPickEmoji'
|
||||
| 'onTextTooLong'
|
||||
| 'platform'
|
||||
>;
|
||||
|
||||
export function StoryCreator({
|
||||
|
@ -110,6 +116,9 @@ export function StoryCreator({
|
|||
i18n,
|
||||
imageToBlurHash,
|
||||
installedPacks,
|
||||
isFormattingEnabled,
|
||||
isFormattingFlagEnabled,
|
||||
isFormattingSpoilersFlagEnabled,
|
||||
isSending,
|
||||
linkPreview,
|
||||
me,
|
||||
|
@ -118,19 +127,21 @@ export function StoryCreator({
|
|||
onDeleteList,
|
||||
onDistributionListCreated,
|
||||
onHideMyStoriesFrom,
|
||||
onMediaPlaybackStart,
|
||||
onPickEmoji,
|
||||
onRemoveMembers,
|
||||
onRepliesNReactionsChanged,
|
||||
onSelectedStoryList,
|
||||
onSend,
|
||||
onSetSkinTone,
|
||||
onTextTooLong,
|
||||
onUseEmoji,
|
||||
onViewersUpdated,
|
||||
onMediaPlaybackStart,
|
||||
ourConversationId,
|
||||
platform,
|
||||
processAttachment,
|
||||
recentEmojis,
|
||||
recentStickers,
|
||||
renderCompositionTextArea,
|
||||
sendStoryModalOpenStateChanged,
|
||||
setMyStoriesToAllSignalConnections,
|
||||
signalConnections,
|
||||
|
@ -234,17 +245,19 @@ export function StoryCreator({
|
|||
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
|
||||
/>
|
||||
)}
|
||||
{draftAttachment && !isReadyToSend && attachmentUrl && (
|
||||
{draftAttachment && attachmentUrl && (
|
||||
<MediaEditor
|
||||
doneButtonLabel={i18n('icu:next2')}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
i18n={i18n}
|
||||
imageSrc={attachmentUrl}
|
||||
imageToBlurHash={imageToBlurHash}
|
||||
installedPacks={installedPacks}
|
||||
isFormattingEnabled={isFormattingEnabled}
|
||||
isFormattingFlagEnabled={isFormattingFlagEnabled}
|
||||
isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled}
|
||||
isSending={isSending}
|
||||
onClose={onClose}
|
||||
supportsCaption
|
||||
renderCompositionTextArea={renderCompositionTextArea}
|
||||
imageToBlurHash={imageToBlurHash}
|
||||
onDone={({
|
||||
contentType,
|
||||
data,
|
||||
|
@ -263,7 +276,11 @@ export function StoryCreator({
|
|||
setBodyRanges(captionBodyRanges);
|
||||
setIsReadyToSend(true);
|
||||
}}
|
||||
onPickEmoji={onPickEmoji}
|
||||
onTextTooLong={onTextTooLong}
|
||||
platform={platform}
|
||||
recentStickers={recentStickers}
|
||||
skinTone={skinTone}
|
||||
/>
|
||||
)}
|
||||
{!file && (
|
||||
|
|
|
@ -25,6 +25,7 @@ export type OwnProps = Readonly<{
|
|||
emoji?: string;
|
||||
i18n: LocalizerType;
|
||||
onClose?: () => unknown;
|
||||
onOpen?: () => unknown;
|
||||
emojiButtonApi?: MutableRefObject<EmojiButtonAPI | undefined>;
|
||||
variant?: EmojiButtonVariant;
|
||||
}>;
|
||||
|
@ -47,6 +48,7 @@ export const EmojiButton = React.memo(function EmojiButtonInner({
|
|||
i18n,
|
||||
doSend,
|
||||
onClose,
|
||||
onOpen,
|
||||
onPickEmoji,
|
||||
skinTone,
|
||||
onSetSkinTone,
|
||||
|
@ -58,6 +60,13 @@ export const EmojiButton = React.memo(function EmojiButtonInner({
|
|||
const popperRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const refMerger = useRefMerger();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
onOpen?.();
|
||||
}, [open, onOpen]);
|
||||
|
||||
const handleClickButton = React.useCallback(() => {
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue