// Copyright 2019-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable no-param-reassign */ import { useMemo } from 'react'; import type { Draft } from 'redux-ts-utils'; import { createAction, handleAction, reduceReducers } from 'redux-ts-utils'; import { useDispatch, useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import { clamp, find, isNumber, isString, pull, remove, take, uniq, } from 'lodash'; import type { SortEnd } from 'react-sortable-hoc'; import { bindActionCreators } from 'redux'; import arrayMove from 'array-move'; import type { AppState } from '../reducer'; import type { PackMetaData, StickerImageData, StickerData, } from '../../util/preload'; import type { EmojiPickDataType } from '../../../ts/components/emoji/EmojiPicker'; import { convertShortName } from '../../../ts/components/emoji/lib'; import { isNotNil } from '../../../ts/util/isNotNil'; export const initializeStickers = createAction>( 'stickers/initializeStickers' ); export const addImageData = createAction( 'stickers/addSticker' ); export const removeSticker = createAction('stickers/removeSticker'); export const moveSticker = createAction('stickers/moveSticker'); export const setCover = createAction('stickers/setCover'); export const resetCover = createAction('stickers/resetCover'); export const setEmoji = createAction<{ id: string; emoji: EmojiPickDataType }>('stickers/setEmoji'); 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?: Record; }>('stickers/addToast'); export const dismissToast = createAction('stickers/dismissToast'); export const resetStatus = createAction('stickers/resetStatus'); export const reset = createAction('stickers/reset'); export const minStickers = 1; export const maxStickers = 200; export const maxByteSize = 300 * 1024; type StateStickerData = { readonly imageData?: StickerImageData; readonly emoji?: EmojiPickDataType; }; type StateToastData = { key: string; subs?: Record; }; export type State = { readonly order: Array; readonly cover?: StickerImageData; readonly title: string; readonly author: string; readonly packId: string; readonly packKey: string; readonly toasts: Array; readonly data: { readonly [src: string]: StateStickerData; }; }; export type Actions = { addImageData: typeof addImageData; initializeStickers: typeof initializeStickers; removeSticker: typeof removeSticker; moveSticker: typeof moveSticker; setCover: typeof setCover; setEmoji: typeof setEmoji; setTitle: typeof setTitle; setAuthor: typeof setAuthor; setPackMeta: typeof setPackMeta; addToast: typeof addToast; dismissToast: typeof dismissToast; reset: typeof reset; resetStatus: typeof resetStatus; }; const defaultState: State = { order: [], data: {}, title: '', author: '', packId: '', packKey: '', toasts: [], }; const adjustCover = (state: Draft) => { const first = state.order[0]; if (first) { state.cover = state.data[first].imageData; } else { delete state.cover; } }; export const reducer = reduceReducers( [ handleAction(initializeStickers, (state, { payload }) => { const truncated = take( uniq([...state.order, ...payload]), maxStickers - state.order.length ); truncated.forEach(path => { if (!state.data[path]) { state.data[path] = {}; state.order.push(path); } }); }), handleAction(addImageData, (state, { payload }) => { if (isNumber(payload.meta.pages)) { state.toasts.push({ key: 'StickerCreator--Toasts--animated' }); pull(state.order, payload.path); delete state.data[payload.path]; } else if (payload.buffer.byteLength > maxByteSize) { 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 we are adding image data, proceed to update the state and add/update a toast if (data && !data.imageData) { data.imageData = payload; const key = 'icu:StickerCreator--Toasts--imagesAdded'; const toast = (() => { const oldToast = find(state.toasts, { key }); if (oldToast) { return oldToast; } const newToast = { key, subs: { count: '0' } }; state.toasts.push(newToast); return newToast; })(); const previousSub = toast?.subs?.count; if (toast && isString(previousSub)) { const previousCount = parseInt(previousSub, 10); const newCount = Number.isFinite(previousCount) ? previousCount + 1 : 1; toast.subs = toast.subs || {}; toast.subs.count = newCount.toString(); } } } adjustCover(state); }), handleAction(removeSticker, (state, { payload }) => { pull(state.order, payload); delete state.data[payload]; adjustCover(state); }), handleAction(moveSticker, (state, { payload }) => { arrayMove.mutate(state.order, payload.oldIndex, payload.newIndex); }), handleAction(setCover, (state, { payload }) => { state.cover = payload; }), handleAction(resetCover, state => { adjustCover(state); }), handleAction(setEmoji, (state, { payload }) => { const data = state.data[payload.id]; if (data) { data.emoji = payload.emoji; } }), handleAction(setTitle, (state, { payload }) => { state.title = payload; }), handleAction(setAuthor, (state, { payload }) => { state.author = payload; }), handleAction(setPackMeta, (state, { payload: { packId, key } }) => { state.packId = packId; 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.toasts = []; }), handleAction(reset, () => defaultState), ], defaultState ); export const useTitle = (): string => useSelector(({ stickers }: AppState) => stickers.title); export const useAuthor = (): string => useSelector(({ stickers }: AppState) => stickers.author); export const useCover = (): StickerImageData | undefined => useSelector(({ stickers }: AppState) => stickers.cover); export const useStickerOrder = (): Array => useSelector(({ stickers }: AppState) => stickers.order); export const useStickerData = (src: string): StateStickerData => useSelector(({ stickers }: AppState) => stickers.data[src]); export const useStickersReady = (): boolean => useSelector( ({ stickers }: AppState) => stickers.order.length >= minStickers && stickers.order.length <= maxStickers && Object.values(stickers.data).every(({ imageData }) => Boolean(imageData)) ); export const useEmojisReady = (): boolean => useSelector(({ stickers }: AppState) => Object.values(stickers.data).every(({ emoji }) => !!emoji) ); export const useAllDataValid = (): boolean => { const stickersReady = useStickersReady(); const emojisReady = useEmojisReady(); const cover = useCover(); const title = useTitle(); const author = useAuthor(); return !!(stickersReady && emojisReady && cover && title && author); }; const selectUrl = createSelector( ({ stickers }: AppState) => stickers.packId, ({ stickers }: AppState) => stickers.packKey, (id, key) => `https://signal.art/addstickers/#pack_id=${id}&pack_key=${key}` ); export const usePackUrl = (): string => useSelector(selectUrl); export const useToasts = (): Array => useSelector(({ stickers }: AppState) => stickers.toasts); export const useAddMoreCount = (): number => useSelector(({ stickers }: AppState) => clamp(minStickers - stickers.order.length, 0, minStickers) ); const selectOrderedData = createSelector( ({ stickers }: AppState) => stickers.order, ({ stickers }: AppState) => stickers.data, (order, data) => order.map(id => ({ ...data[id], emoji: convertShortName( (data[id].emoji as EmojiPickDataType).shortName, (data[id].emoji as EmojiPickDataType).skinTone ), })) ); export const useSelectOrderedData = (): Array => useSelector(selectOrderedData); const selectOrderedImagePaths = createSelector( selectOrderedData, (data: Array) => data.map(({ imageData }) => imageData?.src).filter(isNotNil) ); export const useOrderedImagePaths = (): Array => useSelector(selectOrderedImagePaths); export const useStickerActions = (): Actions => { const dispatch = useDispatch(); return useMemo( () => bindActionCreators( { addImageData, initializeStickers, removeSticker, moveSticker, setCover, setEmoji, setTitle, setAuthor, setPackMeta, addToast, dismissToast, reset, resetStatus, }, dispatch ), [dispatch] ); };