Added story preview and confirmation dialogs to send story

Co-authored-by: Alvaro <110414366+alvaro-signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2022-10-12 09:34:22 -07:00 committed by GitHub
parent 3cf661bad4
commit 5570d6935b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 315 additions and 109 deletions

View File

@ -18,6 +18,19 @@
}
}
.module-SearchInput__container {
margin-left: 0;
margin-right: 0;
}
&__story-preview {
height: 140px;
width: 80px;
border-radius: 12px;
margin: 0 auto 16px auto;
background-size: cover;
}
&__item--contact-or-conversation {
height: 52px;
padding: 0 6px;
@ -32,13 +45,17 @@
}
&__new-story {
&__container {
&__button {
font-weight: 500;
padding: 5px 10px;
&::before {
@include color-svg('../images/icons/v2/plus-20.svg', $color-white);
content: '';
height: 16px;
margin-right: 8px;
width: 16px;
margin-right: 8px;
display: inline-block;
vertical-align: text-bottom;
}
}
@ -121,11 +138,13 @@
&__name {
@include font-body-1-bold;
font-weight: 400;
}
&__description {
@include font-body-2;
color: $color-gray-60;
font-size: 12px;
color: $color-gray-25;
}
&__checkbox {
@ -187,6 +206,9 @@
padding-right: 16px;
user-select: none;
flex: 1;
display: flex;
align-items: center;
min-height: 32px;
}
&__ok {
@ -232,4 +254,15 @@
width: 18px;
}
}
// more specific to override StoryImage
.SendStoryModal {
&__story {
border-radius: 12px;
backdrop-filter: blur(90px);
&__image {
object-fit: contain;
}
}
}
}

View File

@ -15,7 +15,7 @@ export const ConfirmDiscardDialog = ({
i18n,
onClose,
onDiscard,
}: PropsType): JSX.Element | null => {
}: PropsType): JSX.Element => {
return (
<ConfirmationDialog
dialogName="ConfirmDiscardDialog"

View File

@ -34,5 +34,5 @@ const getDefaultProps = (): PropsType<number> => ({
});
export const Default = (): JSX.Element => {
return <ContextMenu {...getDefaultProps()} />;
return <ContextMenu {...getDefaultProps()}>Menu</ContextMenu>;
};

View File

@ -23,20 +23,29 @@ export type ContextMenuOptionType<T> = {
readonly value?: T;
};
export type PropsType<T> = {
readonly ariaLabel?: string;
readonly children?: ReactNode;
readonly i18n: LocalizerType;
readonly menuOptions: ReadonlyArray<ContextMenuOptionType<T>>;
readonly moduleClassName?: string;
readonly onClick?: () => unknown;
readonly onMenuShowingChanged?: (value: boolean) => unknown;
readonly popperOptions?: Pick<Options, 'placement' | 'strategy'>;
readonly theme?: Theme;
readonly title?: string;
readonly value?: T;
type RenderButtonProps = {
openMenu: (() => void) | ((ev: React.MouseEvent) => void);
onKeyDown: (ev: KeyboardEvent) => void;
isMenuShowing: boolean;
ref: React.Ref<HTMLButtonElement> | null;
};
export type PropsType<T> = Readonly<{
ariaLabel?: string;
// contents of the button OR a function that will render the whole button
children?: ReactNode | ((props: RenderButtonProps) => JSX.Element);
i18n: LocalizerType;
menuOptions: ReadonlyArray<ContextMenuOptionType<T>>;
moduleClassName?: string;
button?: () => JSX.Element;
onClick?: () => unknown;
onMenuShowingChanged?: (value: boolean) => unknown;
popperOptions?: Pick<Options, 'placement' | 'strategy'>;
theme?: Theme;
title?: string;
value?: T;
}>;
let closeCurrentOpenContextMenu: undefined | (() => unknown);
// https://popper.js.org/docs/v2/virtual-elements/
@ -167,13 +176,16 @@ export function ContextMenu<T>({
const getClassName = getClassNamesFor('ContextMenu', moduleClassName);
return (
<div
className={classNames(
getClassName('__container'),
theme ? themeClassName(theme) : undefined
)}
>
let buttonNode: ReactNode;
if (typeof children === 'function') {
buttonNode = (children as (props: RenderButtonProps) => JSX.Element)({
openMenu: onClick || handleClick,
onKeyDown: handleKeyDown,
isMenuShowing,
ref: setReferenceElement,
});
} else {
buttonNode = (
<button
aria-label={ariaLabel || i18n('ContextMenu--button')}
className={classNames(
@ -188,6 +200,17 @@ export function ContextMenu<T>({
>
{children}
</button>
);
}
return (
<div
className={classNames(
getClassName('__container'),
theme ? themeClassName(theme) : undefined
)}
>
{buttonNode}
{isMenuShowing && (
<FocusTrap
focusTrapOptions={{

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import Measure from 'react-measure';
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import classNames from 'classnames';
import { createPortal } from 'react-dom';
import { fabric } from 'fabric';
@ -38,6 +38,7 @@ import { AddCaptionModal } from './AddCaptionModal';
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
import { Emojify } from './conversation/Emojify';
import { AddNewLines } from './conversation/AddNewLines';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
export type PropsType = {
doneButtonLabel?: string;
@ -173,6 +174,12 @@ export const MediaEditor = ({
const [editMode, setEditMode] = useState<EditMode | undefined>();
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard(i18n);
const onTryClose = useCallback(() => {
confirmDiscardIf(caption !== '' || Boolean(image), onClose);
}, [confirmDiscardIf, caption, image, onClose]);
// Keyboard support
useEffect(() => {
if (!fabricCanvas) {
@ -207,7 +214,7 @@ export const MediaEditor = ({
// there's no easy way to prevent an ESC meant for the
// sticker-picker from hitting this handler first
if (!isStickerPopperOpen) {
onClose();
onTryClose();
}
} else {
setEditMode(undefined);
@ -349,7 +356,7 @@ export const MediaEditor = ({
fabricCanvas,
editMode,
isStickerPopperOpen,
onClose,
onTryClose,
redoIfPossible,
undoIfPossible,
]);
@ -957,7 +964,7 @@ export const MediaEditor = ({
)}
<div className="MediaEditor__toolbar--buttons">
<Button
onClick={onClose}
onClick={onTryClose}
theme={Theme.Dark}
variant={ButtonVariant.Secondary}
>
@ -1142,7 +1149,7 @@ export const MediaEditor = ({
data = await canvasToBytes(renderedCanvas);
} catch (err) {
onClose();
onTryClose();
throw err;
} finally {
setIsSaving(false);
@ -1157,6 +1164,7 @@ export const MediaEditor = ({
</Button>
</div>
</div>
{confirmDiscardModal}
</div>,
portal
);

View File

@ -16,6 +16,7 @@ import {
getMyStories,
getFakeDistributionListsWithMembers,
} from '../test-both/helpers/getFakeDistributionLists';
import { VIDEO_MP4 } from '../types/MIME';
const i18n = setupI18n('en', enMessages);
@ -28,6 +29,13 @@ export default {
title: 'Components/SendStoryModal',
component: SendStoryModal,
argTypes: {
draftAttachment: {
defaultValue: {
contentType: VIDEO_MP4,
fileName: 'pixabay-Soap-Bubble-7141.mp4',
url: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
},
},
candidateConversations: {
defaultValue: Array.from(Array(100), () => getDefaultConversation()),
},

View File

@ -15,7 +15,7 @@ import type { StoryDistributionListWithMembersDataType } from '../types/Stories'
import type { UUIDStringType } from '../types/UUID';
import { Alert } from './Alert';
import { Avatar, AvatarSize } from './Avatar';
import { Button, ButtonVariant } from './Button';
import { Button, ButtonSize, ButtonVariant } from './Button';
import { Checkbox } from './Checkbox';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenu } from './ContextMenu';
@ -31,8 +31,14 @@ import { PagedModal, ModalPage } from './Modal';
import { StoryDistributionListName } from './StoryDistributionListName';
import { Theme } from '../util/theme';
import { isNotNil } from '../util/isNotNil';
import { StoryImage } from './StoryImage';
import type { AttachmentType } from '../types/Attachment';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
import { getStoryBackground } from '../util/getStoryBackground';
import { makeObjectUrl, revokeObjectUrl } from '../types/VisualAttachment';
export type PropsType = {
draftAttachment: AttachmentType;
candidateConversations: Array<ConversationType>;
distributionLists: Array<StoryDistributionListWithMembersDataType>;
getPreferredBadge: PreferredBadgeSelectorType;
@ -114,6 +120,7 @@ function getListViewers(
}
export const SendStoryModal = ({
draftAttachment,
candidateConversations,
distributionLists,
getPreferredBadge,
@ -138,6 +145,8 @@ export const SendStoryModal = ({
}: PropsType): JSX.Element => {
const [page, setPage] = useState<PageType>(Page.SendStory);
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard(i18n);
const [selectedListIds, setSelectedListIds] = useState<Set<UUIDStringType>>(
new Set()
);
@ -267,6 +276,24 @@ export const SendStoryModal = ({
.join(', ');
}
const [objectUrl, setObjectUrl] = useState<string | undefined>(undefined);
useEffect(() => {
let url: undefined | string;
if (draftAttachment.url) {
setObjectUrl(draftAttachment.url);
} else if (draftAttachment.data) {
url = makeObjectUrl(draftAttachment.data, draftAttachment.contentType);
setObjectUrl(url);
}
return () => {
if (url) {
revokeObjectUrl(url);
}
};
}, [setObjectUrl, draftAttachment]);
const modalCommonProps: Pick<ModalPropsType, 'hasXButton' | 'i18n'> = {
hasXButton: true,
i18n,
@ -375,7 +402,11 @@ export const SendStoryModal = ({
setPage={setPage}
setSelectedContacts={setSelectedContacts}
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
onBackButtonClick={() => setListIdToEdit(undefined)}
onBackButtonClick={() =>
confirmDiscardIf(selectedContacts.length > 0, () =>
setListIdToEdit(undefined)
)
}
onClose={handleClose}
/>
);
@ -412,29 +443,31 @@ export const SendStoryModal = ({
}}
page={page}
onClose={handleClose}
onBackButtonClick={() => {
if (listIdToEdit) {
if (
page === Page.AddViewer ||
page === Page.HideStoryFrom ||
page === Page.ChooseViewers
) {
setPage(Page.EditingDistributionList);
} else {
setListIdToEdit(undefined);
onBackButtonClick={() =>
confirmDiscardIf(selectedContacts.length > 0, () => {
if (listIdToEdit) {
if (
page === Page.AddViewer ||
page === Page.HideStoryFrom ||
page === Page.ChooseViewers
) {
setPage(Page.EditingDistributionList);
} else {
setListIdToEdit(undefined);
}
} else if (page === Page.HideStoryFrom || page === Page.AddViewer) {
setSelectedContacts([]);
setStagedMyStories(initialMyStories);
setStagedMyStoriesMemberUuids(initialMyStoriesMemberUuids);
setPage(Page.SetMyStoriesPrivacy);
} else if (page === Page.ChooseViewers) {
setSelectedContacts([]);
setPage(Page.SendStory);
} else if (page === Page.NameStory) {
setPage(Page.ChooseViewers);
}
} else if (page === Page.HideStoryFrom || page === Page.AddViewer) {
setSelectedContacts([]);
setStagedMyStories(initialMyStories);
setStagedMyStoriesMemberUuids(initialMyStoriesMemberUuids);
setPage(Page.SetMyStoriesPrivacy);
} else if (page === Page.ChooseViewers) {
setSelectedContacts([]);
setPage(Page.SendStory);
} else if (page === Page.NameStory) {
setPage(Page.ChooseViewers);
}
}}
})
}
selectedContacts={selectedContacts}
setSelectedContacts={setSelectedContacts}
/>
@ -443,17 +476,19 @@ export const SendStoryModal = ({
const footer = (
<>
<div className="SendStoryModal__selected-lists">{selectedNames}</div>
<button
aria-label={i18n('ok')}
className="SendStoryModal__ok"
disabled={!chosenGroupIds.size}
onClick={() => {
toggleGroupsForStorySend(Array.from(chosenGroupIds));
setChosenGroupIds(new Set());
setPage(Page.SendStory);
}}
type="button"
/>
{selectedNames.length > 0 && (
<button
aria-label={i18n('ok')}
className="SendStoryModal__ok"
disabled={!chosenGroupIds.size}
onClick={() => {
toggleGroupsForStorySend(Array.from(chosenGroupIds));
setChosenGroupIds(new Set());
setPage(Page.SendStory);
}}
type="button"
/>
)}
</>
);
@ -461,6 +496,7 @@ export const SendStoryModal = ({
<ModalPage
modalName="SendStoryModal__choose-groups"
title={i18n('SendStoryModal__choose-groups')}
moduleClassName="SendStoryModal"
modalFooter={footer}
onClose={handleClose}
{...modalCommonProps}
@ -548,17 +584,25 @@ export const SendStoryModal = ({
const footer = (
<>
<div className="SendStoryModal__selected-lists">{selectedNames}</div>
<button
aria-label={i18n('SendStoryModal__send')}
className="SendStoryModal__send"
disabled={!selectedListIds.size && !selectedGroupIds.size}
onClick={() => {
onSend(Array.from(selectedListIds), Array.from(selectedGroupIds));
}}
type="button"
/>
{selectedNames.length > 0 && (
<button
aria-label={i18n('SendStoryModal__send')}
className="SendStoryModal__send"
disabled={!selectedListIds.size && !selectedGroupIds.size}
onClick={() => {
onSend(Array.from(selectedListIds), Array.from(selectedGroupIds));
}}
type="button"
/>
)}
</>
);
const attachment = {
...draftAttachment,
url: objectUrl,
};
modal = handleClose => (
<ModalPage
modalName="SendStoryModal__title"
@ -568,6 +612,20 @@ export const SendStoryModal = ({
onClose={handleClose}
{...modalCommonProps}
>
<div
className="SendStoryModal__story-preview"
style={{ backgroundImage: getStoryBackground(attachment) }}
>
<StoryImage
i18n={i18n}
firstName={i18n('you')}
queueStoryDownload={noop}
storyId="story-id"
label="label"
moduleClassName="SendStoryModal__story"
attachment={attachment}
/>
</div>
<div className="SendStoryModal__top-bar">
{i18n('stories')}
<ContextMenu
@ -594,7 +652,18 @@ export const SendStoryModal = ({
}}
theme={Theme.Dark}
>
{i18n('SendStoryModal__new')}
{({ openMenu, onKeyDown, ref }) => (
<Button
ref={ref}
className="SendStoryModal__new-story__button"
variant={ButtonVariant.Secondary}
size={ButtonSize.Small}
onClick={openMenu}
onKeyDown={onKeyDown}
>
{i18n('SendStoryModal__new')}
</Button>
)}
</ContextMenu>
</div>
{distributionLists.map(list => (
@ -794,13 +863,15 @@ export const SendStoryModal = ({
return (
<>
<PagedModal
modalName="SendStoryModal"
theme={Theme.Dark}
onClose={onClose}
>
{modal}
</PagedModal>
{!confirmDiscardModal && (
<PagedModal
modalName="SendStoryModal"
theme={Theme.Dark}
onClose={() => confirmDiscardIf(selectedContacts.length > 0, onClose)}
>
{modal}
</PagedModal>
)}
{hasAnnouncementsOnlyAlert && (
<Alert
body={i18n('SendStoryModal__announcements-only')}
@ -852,6 +923,7 @@ export const SendStoryModal = ({
{i18n('StoriesSettings__delete-list--confirm')}
</ConfirmationDialog>
)}
{confirmDiscardModal}
</>
);
};

View File

@ -92,7 +92,8 @@ export const Stories = ({
useEscapeHandling(
(isMyStories && myStories.length) ||
isViewingStory ||
isStoriesSettingsVisible
isStoriesSettingsVisible ||
addStoryData
? undefined
: toggleStoriesView
);

View File

@ -35,6 +35,7 @@ import {
shouldNeverBeCalled,
asyncShouldNeverBeCalled,
} from '../util/shouldNeverBeCalled';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
export type PropsType = {
candidateConversations: Array<ConversationType>;
@ -103,6 +104,8 @@ export const StoriesSettingsModal = ({
setMyStoriesToAllSignalConnections,
toggleSignalConnectionsModal,
}: PropsType): JSX.Element => {
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard(i18n);
const [listToEditId, setListToEditId] = useState<string | undefined>(
undefined
);
@ -144,17 +147,19 @@ export const StoriesSettingsModal = ({
onDistributionListCreated(name, uuids);
resetChooseViewersScreen();
}}
onBackButtonClick={() => {
if (page === Page.HideStoryFrom) {
resetChooseViewersScreen();
} else if (page === Page.NameStory) {
setPage(Page.ChooseViewers);
} else if (isChoosingViewers) {
resetChooseViewersScreen();
} else if (listToEdit) {
setListToEditId(undefined);
}
}}
onBackButtonClick={() =>
confirmDiscardIf(selectedContacts.length > 0, () => {
if (page === Page.HideStoryFrom) {
resetChooseViewersScreen();
} else if (page === Page.NameStory) {
setPage(Page.ChooseViewers);
} else if (isChoosingViewers) {
resetChooseViewersScreen();
} else if (listToEdit) {
setListToEditId(undefined);
}
})
}
onViewersUpdated={uuids => {
if (listToEditId && page === Page.AddViewer) {
onViewersUpdated(listToEditId, uuids);
@ -175,7 +180,7 @@ export const StoriesSettingsModal = ({
/>
);
} else if (listToEdit) {
modal = onClose => (
modal = handleClose => (
<DistributionListSettingsModal
key="settings-modal"
getPreferredBadge={getPreferredBadge}
@ -189,7 +194,7 @@ export const StoriesSettingsModal = ({
setSelectedContacts={setSelectedContacts}
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
onBackButtonClick={() => setListToEditId(undefined)}
onClose={onClose}
onClose={handleClose}
/>
);
} else {
@ -281,13 +286,17 @@ export const StoriesSettingsModal = ({
return (
<>
<PagedModal
modalName="StoriesSettingsModal"
theme={Theme.Dark}
onClose={hideStoriesSettings}
>
{modal}
</PagedModal>
{!confirmDiscardModal && (
<PagedModal
modalName="StoriesSettingsModal"
theme={Theme.Dark}
onClose={() =>
confirmDiscardIf(selectedContacts.length > 0, hideStoriesSettings)
}
>
{modal}
</PagedModal>
)}
{confirmDeleteListId && (
<ConfirmationDialog
dialogName="StoriesSettings.deleteList"
@ -309,6 +318,7 @@ export const StoriesSettingsModal = ({
{i18n('StoriesSettings__delete-list--confirm')}
</ConfirmationDialog>
)}
{confirmDiscardModal}
</>
);
};
@ -396,7 +406,7 @@ export const DistributionListSettingsModal = ({
{isMyStories && (
<EditMyStoriesPrivacy
i18n={i18n}
learnMore="StoriesSettings__mine_disclaimer"
learnMore="StoriesSettings__mine__disclaimer"
myStories={listToEdit}
onClickExclude={() => {
setPage(Page.HideStoryFrom);
@ -862,6 +872,7 @@ export const EditDistributionListModal = ({
}}
value={searchTerm}
/>
{selectedContacts.length ? (
<ContactPills moduleClassName="StoriesSettingsModal__tags">
{selectedContacts.map(contact => (

View File

@ -143,6 +143,7 @@ export const StoryCreator = ({
<>
{draftAttachment && (
<SendStoryModal
draftAttachment={draftAttachment}
candidateConversations={candidateConversations}
distributionLists={distributionLists}
getPreferredBadge={getPreferredBadge}

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import FocusTrap from 'focus-trap-react';
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { get, has, noop } from 'lodash';
import { usePopper } from 'react-popper';
@ -28,6 +28,7 @@ import {
} from '../util/getStoryBackground';
import { objectMap } from '../util/objectMap';
import { handleOutsideClick } from '../util/handleOutsideClick';
import { ConfirmDiscardDialog } from './ConfirmDiscardDialog';
export type PropsType = {
debouncedMaybeGrabLinkPreview: (
@ -125,6 +126,12 @@ export const TextStoryCreator = ({
onClose,
onDone,
}: 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);
@ -252,11 +259,11 @@ export const TextStoryCreator = ({
setIsColorPickerShowing(false);
setIsEditingText(false);
setIsLinkPreviewInputShowing(false);
event.preventDefault();
event.stopPropagation();
} else {
onClose();
onTryClose();
}
event.preventDefault();
event.stopPropagation();
}
};
@ -271,7 +278,9 @@ export const TextStoryCreator = ({
isEditingText,
isLinkPreviewInputShowing,
colorPickerPopperButtonRef,
onClose,
showConfirmDiscardModal,
setShowConfirmDiscardModal,
onTryClose,
]);
useEffect(() => {
@ -422,7 +431,7 @@ export const TextStoryCreator = ({
)}
<div className="StoryCreator__toolbar--buttons">
<Button
onClick={onClose}
onClick={onTryClose}
theme={Theme.Dark}
variant={ButtonVariant.Secondary}
>
@ -566,6 +575,13 @@ export const TextStoryCreator = ({
</Button>
</div>
</div>
{showConfirmDiscardModal && (
<ConfirmDiscardDialog
i18n={i18n}
onClose={() => setShowConfirmDiscardModal(false)}
onDiscard={onClose}
/>
)}
</div>
</FocusTrap>
);

View File

@ -0,0 +1,33 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import type { PropsType } from '../components/ConfirmDiscardDialog';
import { ConfirmDiscardDialog } from '../components/ConfirmDiscardDialog';
import type { LocalizerType } from '../types/Util';
export function useConfirmDiscard(
i18n: LocalizerType
): [JSX.Element | null, (condition: boolean, callback: () => void) => void] {
const [props, setProps] = useState<Omit<PropsType, 'i18n'> | null>(null);
const confirmElement = props ? (
<ConfirmDiscardDialog i18n={i18n} {...props} />
) : null;
function confirmDiscardIf(condition: boolean, callback: () => void) {
if (condition) {
setProps({
onClose() {
setProps(null);
},
onDiscard: callback,
});
} else {
callback();
}
}
return [confirmElement, confirmDiscardIf];
}