diff --git a/_locales/en/messages.json b/_locales/en/messages.json index ee1eeea28..b87ad2168 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2353,12 +2353,27 @@ "StickerCreator--Toasts--animated": { "message": "Animated stickers are not currently supported", "description": - "Text for the toast when an image that is animated was dropped" + "Text for the toast when an image that is animated was dropped on the sticker creator" }, "StickerCreator--Toasts--tooLarge": { "message": "Dropped image is too large", "description": - "Text for the toast when an image that is too large was dropped" + "Text for the toast when an image that is too large was dropped on the sticker creator" + }, + "StickerCreator--Toasts--errorProcessing": { + "message": "Error processing image", + "description": + "Text for the toast when an image cannot be processed was dropped on the sticker creator" + }, + "StickerCreator--Toasts--errorUploading": { + "message": "Error uploading stickers: $message$", + "description": "Text for the toast when a sticker pack cannot be uploaded", + "placeholders": { + "message": { + "content": "$1", + "example": "Not connected" + } + } }, "StickerCreator--Toasts--linkedCopied": { "message": "Link copied", diff --git a/sticker-creator/app/stages/AppStage.tsx b/sticker-creator/app/stages/AppStage.tsx index 0f22b5d89..1e0e8bfbb 100644 --- a/sticker-creator/app/stages/AppStage.tsx +++ b/sticker-creator/app/stages/AppStage.tsx @@ -4,6 +4,7 @@ import { history } from '../../util/history'; import { Button } from '../../elements/Button'; import { useI18n } from '../../util/i18n'; import { Text } from '../../elements/Typography'; +import { Toaster } from '../../components/Toaster'; import { stickersDuck } from '../../store'; export type Props = { @@ -59,6 +60,8 @@ export const AppStage = (props: Props) => { ); const addMoreCount = stickersDuck.useAddMoreCount(); + const toasts = stickersDuck.useToasts(); + const { dismissToast } = stickersDuck.useStickerActions(); return ( <> @@ -85,6 +88,14 @@ export const AppStage = (props: Props) => { ) : null} + ({ + id, + text: i18n(slice.key, slice.subs), + }))} + onDismiss={dismissToast} + /> ); }; diff --git a/sticker-creator/app/stages/DropStage.tsx b/sticker-creator/app/stages/DropStage.tsx index 041b36dad..eb2f0600d 100644 --- a/sticker-creator/app/stages/DropStage.tsx +++ b/sticker-creator/app/stages/DropStage.tsx @@ -1,68 +1,17 @@ import * as React from 'react'; import { AppStage } from './AppStage'; import * as styles from './DropStage.scss'; -import * as appStyles from './AppStage.scss'; import { H2, Text } from '../../elements/Typography'; import { LabeledCheckbox } from '../../elements/LabeledCheckbox'; -import { Toast } from '../../elements/Toast'; import { StickerGrid } from '../../components/StickerGrid'; import { stickersDuck } from '../../store'; import { useI18n } from '../../util/i18n'; -const renderToaster = ({ - hasAnimated, - hasTooLarge, - numberAdded, - resetStatus, - i18n, -}: { - hasAnimated: boolean; - hasTooLarge: boolean; - numberAdded: number; - resetStatus: () => unknown; - i18n: ReturnType; -}) => { - if (hasAnimated) { - return ( -
- - {i18n('StickerCreator--Toasts--animated')} - -
- ); - } - - if (hasTooLarge) { - return ( -
- - {i18n('StickerCreator--Toasts--tooLarge')} - -
- ); - } - - if (numberAdded > 0) { - return ( -
- - {i18n('StickerCreator--Toasts--imagesAdded', [numberAdded])} - -
- ); - } - - return null; -}; - export const DropStage = () => { const i18n = useI18n(); const stickerPaths = stickersDuck.useStickerOrder(); const stickersReady = stickersDuck.useStickersReady(); const haveStickers = stickerPaths.length > 0; - const hasAnimated = stickersDuck.useHasAnimated(); - const hasTooLarge = stickersDuck.useHasTooLarge(); - const numberAdded = stickersDuck.useImageAddedCount(); const [showGuide, setShowGuide] = React.useState(true); const { resetStatus } = stickersDuck.useStickerActions(); @@ -86,13 +35,6 @@ export const DropStage = () => {
- {renderToaster({ - hasAnimated, - hasTooLarge, - numberAdded, - resetStatus, - i18n, - })} ); }; diff --git a/sticker-creator/app/stages/UploadStage.tsx b/sticker-creator/app/stages/UploadStage.tsx index 241ce63ed..ba6e058b4 100644 --- a/sticker-creator/app/stages/UploadStage.tsx +++ b/sticker-creator/app/stages/UploadStage.tsx @@ -9,6 +9,7 @@ import { Button } from '../../elements/Button'; import { stickersDuck } from '../../store'; import { encryptAndUpload } from '../../util/preload'; import { useI18n } from '../../util/i18n'; +import { Toaster } from '../../components/Toaster'; const handleCancel = () => history.push('/add-meta'); @@ -36,6 +37,9 @@ export const UploadStage = () => { actions.setPackMeta(packMeta); history.push('/share'); } catch (e) { + actions.addToast('StickerCreator--Toasts--errorUploading', [ + e.message, + ]); history.push('/add-meta'); } })(); diff --git a/sticker-creator/components/StickerGrid.tsx b/sticker-creator/components/StickerGrid.tsx index 260e499e4..9d6369599 100644 --- a/sticker-creator/components/StickerGrid.tsx +++ b/sticker-creator/components/StickerGrid.tsx @@ -51,8 +51,13 @@ const InnerGrid = SortableContainer( actions.initializeStickers(paths); paths.forEach(path => { queue.add(async () => { - const webp = await convertToWebp(path); - actions.addWebp(webp); + try { + const webp = await convertToWebp(path); + actions.addWebp(webp); + } catch (e) { + actions.removeSticker(path); + actions.addToast('StickerCreator--Toasts--errorProcessing'); + } }); }); }, diff --git a/sticker-creator/components/Toaster.stories.tsx b/sticker-creator/components/Toaster.stories.tsx new file mode 100644 index 000000000..3227367bf --- /dev/null +++ b/sticker-creator/components/Toaster.stories.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { debounce, dropRight } from 'lodash'; +import { StoryRow } from '../elements/StoryRow'; +import { Toaster } from './Toaster'; + +import { storiesOf } from '@storybook/react'; +import { text as textKnob } from '@storybook/addon-knobs'; + +storiesOf('Sticker Creator/components', module).add('Toaster', () => { + const inputText = textKnob('Slices', ['error 1', 'error 2'].join('|')); + const initialState = React.useMemo(() => inputText.split('|'), [inputText]); + const [state, setState] = React.useState(initialState); + + const handleDismiss = React.useCallback( + // Debounce is required here since auto-dismiss is asynchronously called + // from multiple rendered instances (multiple themes) + debounce(() => { + setState(dropRight); + }, 10), + [setState] + ); + + return ( + + ({ id, text }))} + onDismiss={handleDismiss} + /> + + ); +}); diff --git a/sticker-creator/components/Toaster.tsx b/sticker-creator/components/Toaster.tsx new file mode 100644 index 000000000..5dc33fd60 --- /dev/null +++ b/sticker-creator/components/Toaster.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { last, noop } from 'lodash'; +import { Toast } from '../elements/Toast'; + +export type Props = React.HTMLProps & { + loaf: Array<{ id: number; text: string }>; + onDismiss: () => unknown; +}; + +const DEFAULT_DISMISS = 1e4; + +export const Toaster = React.memo(({ loaf, onDismiss, className }: Props) => { + const slice = last(loaf); + + React.useEffect( + () => { + if (!slice) { + return noop; + } + + const timer = setTimeout(() => { + onDismiss(); + }, DEFAULT_DISMISS); + + return () => { + clearTimeout(timer); + }; + }, + [slice, onDismiss] + ); + + if (!slice) { + return null; + } + + return ( +
+ + {slice.text} + +
+ ); +}); diff --git a/sticker-creator/elements/Toast.scss b/sticker-creator/elements/Toast.scss index 87af83d7e..0c14e9438 100644 --- a/sticker-creator/elements/Toast.scss +++ b/sticker-creator/elements/Toast.scss @@ -11,4 +11,5 @@ font-size: 14px; color: $color-gray-05; line-height: 18px; + cursor: pointer; } diff --git a/sticker-creator/elements/Toast.stories.tsx b/sticker-creator/elements/Toast.stories.tsx index d4cefad11..f3cd8b937 100644 --- a/sticker-creator/elements/Toast.stories.tsx +++ b/sticker-creator/elements/Toast.stories.tsx @@ -4,13 +4,14 @@ import { Toast } from './Toast'; import { storiesOf } from '@storybook/react'; import { text } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; storiesOf('Sticker Creator/elements', module).add('Toast', () => { const child = text('text', 'foo bar'); return ( - {child} + {child} ); }); diff --git a/sticker-creator/elements/Toast.tsx b/sticker-creator/elements/Toast.tsx index c3bbf6d3d..51f9fe8e5 100644 --- a/sticker-creator/elements/Toast.tsx +++ b/sticker-creator/elements/Toast.tsx @@ -1,12 +1,13 @@ import * as React from 'react'; +import classNames from 'classnames'; import * as styles from './Toast.scss'; export type Props = React.HTMLProps & { children: React.ReactNode; }; -export const Toast = React.memo(({ children, ...rest }: Props) => ( - )); diff --git a/sticker-creator/store/ducks/stickers.ts b/sticker-creator/store/ducks/stickers.ts index 19b39e654..f55a771f9 100644 --- a/sticker-creator/store/ducks/stickers.ts +++ b/sticker-creator/store/ducks/stickers.ts @@ -9,7 +9,7 @@ import { } from 'redux-ts-utils'; import { useDispatch, useSelector } from 'react-redux'; import { createSelector } from 'reselect'; -import { clamp, isNumber, pull, take, uniq } from 'lodash'; +import { clamp, find, isNumber, pull, remove, take, uniq } from 'lodash'; import { SortEnd } from 'react-sortable-hoc'; import arrayMove from 'array-move'; import { AppState } from '../reducer'; @@ -31,6 +31,13 @@ export const setEmoji = createAction<{ id: string; emoji: EmojiPickDataType }>( export const setTitle = createAction('stickers/setTitle'); export const setAuthor = createAction('stickers/setAuthor'); export const setPackMeta = createAction('stickers/setPackMeta'); + +export const addToast = createAction<{ + key: string; + subs?: Array; +}>('stickers/addToast'); +export const dismissToast = createAction('stickers/dismissToast'); + export const resetStatus = createAction('stickers/resetStatus'); export const reset = createAction('stickers/reset'); @@ -45,9 +52,7 @@ export type State = { readonly author: string; readonly packId: string; readonly packKey: string; - readonly tooLarge: number; - readonly animated: number; - readonly imagesAdded: number; + readonly toasts: Array<{ key: string; subs?: Array }>; readonly data: { readonly [src: string]: { readonly webp?: WebpData; @@ -63,9 +68,7 @@ const defaultState: State = { author: '', packId: '', packKey: '', - tooLarge: 0, - animated: 0, - imagesAdded: 0, + toasts: [], }; const adjustCover = (state: Draft) => { @@ -95,23 +98,38 @@ export const reducer = reduceReducers( handleAction(addWebp, (state, { payload }) => { if (isNumber(payload.meta.pages)) { - state.animated = clamp(state.animated + 1, 0, state.order.length); + state.toasts.push({ key: 'StickerCreator--Toasts--animated' }); pull(state.order, payload.path); delete state.data[payload.path]; } else if (payload.buffer.byteLength > maxByteSize) { - state.tooLarge = clamp(state.tooLarge + 1, 0, state.order.length); + state.toasts.push({ key: 'StickerCreator--Toasts--tooLarge' }); pull(state.order, payload.path); delete state.data[payload.path]; } else { const data = state.data[payload.path]; - if (data) { + // If we are adding webp data, proceed to update the state and add/update a toast + if (data && !data.webp) { data.webp = payload; - state.imagesAdded = clamp( - state.imagesAdded + 1, - 0, - state.order.length - ); + + const key = 'StickerCreator--Toasts--imagesAdded'; + + const toast = (() => { + const oldToast = find(state.toasts, { key }); + + if (oldToast) { + return oldToast; + } + + const newToast = { key, subs: [0] }; + state.toasts.push(newToast); + + return newToast; + })(); + + if (toast.subs && isNumber(toast.subs[0])) { + toast.subs[0] = (toast.subs[0] || 0) + 1; + } } } @@ -122,7 +140,6 @@ export const reducer = reduceReducers( pull(state.order, payload); delete state.data[payload]; adjustCover(state); - state.imagesAdded = clamp(state.imagesAdded - 1, 0, state.order.length); }), handleAction(moveSticker, (state, { payload }) => { @@ -157,10 +174,17 @@ export const reducer = reduceReducers( state.packKey = key; }), + handleAction(addToast, (state, { payload: toast }) => { + remove(state.toasts, { key: toast.key }); + state.toasts.push(toast); + }), + + handleAction(dismissToast, state => { + state.toasts.pop(); + }), + handleAction(resetStatus, state => { - state.tooLarge = 0; - state.animated = 0; - state.imagesAdded = 0; + state.toasts = []; }), handleAction(reset, () => defaultState), @@ -212,12 +236,8 @@ const selectUrl = createSelector( ); export const usePackUrl = () => useSelector(selectUrl); -export const useHasTooLarge = () => - useSelector(({ stickers }: AppState) => stickers.tooLarge > 0); -export const useHasAnimated = () => - useSelector(({ stickers }: AppState) => stickers.animated > 0); -export const useImageAddedCount = () => - useSelector(({ stickers }: AppState) => stickers.imagesAdded); +export const useToasts = () => + useSelector(({ stickers }: AppState) => stickers.toasts); export const useAddMoreCount = () => useSelector(({ stickers }: AppState) => clamp(minStickers - stickers.order.length, 0, minStickers) @@ -260,6 +280,9 @@ export const useStickerActions = () => { setTitle: (title: string) => dispatch(setTitle(title)), setAuthor: (author: string) => dispatch(setAuthor(author)), setPackMeta: (e: PackMetaData) => dispatch(setPackMeta(e)), + addToast: (key: string, subs?: Array) => + dispatch(addToast({ key, subs })), + dismissToast: () => dispatch(dismissToast()), reset: () => dispatch(reset()), resetStatus: () => dispatch(resetStatus()), }),