Sticker Creator: New toaster implementation, better error handling

This commit is contained in:
Ken Powers 2020-01-06 21:20:16 -05:00 committed by Scott Nonnenberg
parent 4b3d63c82e
commit f7568810ea
11 changed files with 167 additions and 90 deletions

View File

@ -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",

View File

@ -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) => {
</Button>
) : null}
</footer>
<Toaster
className={styles.toaster}
loaf={toasts.map((slice, id) => ({
id,
text: i18n(slice.key, slice.subs),
}))}
onDismiss={dismissToast}
/>
</>
);
};

View File

@ -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<typeof useI18n>;
}) => {
if (hasAnimated) {
return (
<div className={appStyles.toaster}>
<Toast onClick={resetStatus}>
{i18n('StickerCreator--Toasts--animated')}
</Toast>
</div>
);
}
if (hasTooLarge) {
return (
<div className={appStyles.toaster}>
<Toast onClick={resetStatus}>
{i18n('StickerCreator--Toasts--tooLarge')}
</Toast>
</div>
);
}
if (numberAdded > 0) {
return (
<div className={appStyles.toaster}>
<Toast onClick={resetStatus}>
{i18n('StickerCreator--Toasts--imagesAdded', [numberAdded])}
</Toast>
</div>
);
}
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<boolean>(true);
const { resetStatus } = stickersDuck.useStickerActions();
@ -86,13 +35,6 @@ export const DropStage = () => {
<div className={styles.main}>
<StickerGrid mode="add" showGuide={showGuide} />
</div>
{renderToaster({
hasAnimated,
hasTooLarge,
numberAdded,
resetStatus,
i18n,
})}
</AppStage>
);
};

View File

@ -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');
}
})();

View File

@ -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');
}
});
});
},

View File

@ -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 (
<StoryRow>
<Toaster
loaf={state.map((text, id) => ({ id, text }))}
onDismiss={handleDismiss}
/>
</StoryRow>
);
});

View File

@ -0,0 +1,43 @@
import * as React from 'react';
import { last, noop } from 'lodash';
import { Toast } from '../elements/Toast';
export type Props = React.HTMLProps<HTMLDivElement> & {
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 (
<div className={className}>
<Toast key={slice.id} onClick={onDismiss} tabIndex={0}>
{slice.text}
</Toast>
</div>
);
});

View File

@ -11,4 +11,5 @@
font-size: 14px;
color: $color-gray-05;
line-height: 18px;
cursor: pointer;
}

View File

@ -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 (
<StoryRow>
<Toast>{child}</Toast>
<Toast onClick={action('click')}>{child}</Toast>
</StoryRow>
);
});

View File

@ -1,12 +1,13 @@
import * as React from 'react';
import classNames from 'classnames';
import * as styles from './Toast.scss';
export type Props = React.HTMLProps<HTMLButtonElement> & {
children: React.ReactNode;
};
export const Toast = React.memo(({ children, ...rest }: Props) => (
<button className={styles.base} {...rest}>
export const Toast = React.memo(({ children, className, ...rest }: Props) => (
<button className={classNames(styles.base, className)} {...rest}>
{children}
</button>
));

View File

@ -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<string>('stickers/setTitle');
export const setAuthor = createAction<string>('stickers/setAuthor');
export const setPackMeta = createAction<PackMetaData>('stickers/setPackMeta');
export const addToast = createAction<{
key: string;
subs?: Array<number | string>;
}>('stickers/addToast');
export const dismissToast = createAction<void>('stickers/dismissToast');
export const resetStatus = createAction<void>('stickers/resetStatus');
export const reset = createAction<void>('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<number | string> }>;
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<State>) => {
@ -95,23 +98,38 @@ export const reducer = reduceReducers<State>(
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<State>(
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>(
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<number>) =>
dispatch(addToast({ key, subs })),
dismissToast: () => dispatch(dismissToast()),
reset: () => dispatch(reset()),
resetStatus: () => dispatch(resetStatus()),
}),