519 lines
12 KiB
TypeScript
519 lines
12 KiB
TypeScript
// Copyright 2019-2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import { Dictionary, omit, reject } from 'lodash';
|
|
import dataInterface from '../../sql/Client';
|
|
import {
|
|
downloadStickerPack as externalDownloadStickerPack,
|
|
maybeDeletePack,
|
|
} from '../../../js/modules/stickers';
|
|
import { sendStickerPackSync } from '../../shims/textsecure';
|
|
import { trigger } from '../../shims/events';
|
|
|
|
import { NoopActionType } from './noop';
|
|
|
|
const {
|
|
getRecentStickers,
|
|
updateStickerLastUsed,
|
|
updateStickerPackStatus,
|
|
} = dataInterface;
|
|
|
|
// State
|
|
|
|
export type StickerDBType = {
|
|
readonly id: number;
|
|
readonly packId: string;
|
|
|
|
readonly emoji: string | null;
|
|
readonly isCoverOnly: boolean;
|
|
readonly lastUsed: number;
|
|
readonly path: string;
|
|
};
|
|
|
|
export const StickerPackStatuses = [
|
|
'known',
|
|
'ephemeral',
|
|
'downloaded',
|
|
'installed',
|
|
'pending',
|
|
'error',
|
|
] as const;
|
|
|
|
export type StickerPackStatus = typeof StickerPackStatuses[number];
|
|
|
|
export type StickerPackDBType = {
|
|
readonly id: string;
|
|
readonly key: string;
|
|
|
|
readonly attemptedStatus: 'downloaded' | 'installed' | 'ephemeral';
|
|
readonly author: string;
|
|
readonly coverStickerId: number;
|
|
readonly createdAt: number;
|
|
readonly downloadAttempts: number;
|
|
readonly installedAt: number | null;
|
|
readonly lastUsed: number;
|
|
readonly status: StickerPackStatus;
|
|
readonly stickerCount: number;
|
|
readonly stickers: Dictionary<StickerDBType>;
|
|
readonly title: string;
|
|
};
|
|
|
|
export type RecentStickerType = {
|
|
readonly stickerId: number;
|
|
readonly packId: string;
|
|
};
|
|
|
|
export type StickersStateType = {
|
|
readonly installedPack: string | null;
|
|
readonly packs: Dictionary<StickerPackDBType>;
|
|
readonly recentStickers: Array<RecentStickerType>;
|
|
readonly blessedPacks: Dictionary<boolean>;
|
|
};
|
|
|
|
// These are for the React components
|
|
|
|
export type StickerType = {
|
|
readonly id: number;
|
|
readonly packId: string;
|
|
readonly emoji: string | null;
|
|
readonly url: string;
|
|
};
|
|
|
|
export type StickerPackType = {
|
|
readonly id: string;
|
|
readonly key: string;
|
|
readonly title: string;
|
|
readonly author: string;
|
|
readonly isBlessed: boolean;
|
|
readonly cover?: StickerType;
|
|
readonly lastUsed: number;
|
|
readonly attemptedStatus?: 'downloaded' | 'installed' | 'ephemeral';
|
|
readonly status: StickerPackStatus;
|
|
readonly stickers: Array<StickerType>;
|
|
readonly stickerCount: number;
|
|
};
|
|
|
|
// Actions
|
|
|
|
type StickerPackAddedAction = {
|
|
type: 'stickers/STICKER_PACK_ADDED';
|
|
payload: StickerPackDBType;
|
|
};
|
|
|
|
type StickerAddedAction = {
|
|
type: 'stickers/STICKER_ADDED';
|
|
payload: StickerDBType;
|
|
};
|
|
|
|
type InstallStickerPackPayloadType = {
|
|
packId: string;
|
|
fromSync: boolean;
|
|
status: 'installed';
|
|
installedAt: number;
|
|
recentStickers: Array<RecentStickerType>;
|
|
};
|
|
type InstallStickerPackAction = {
|
|
type: 'stickers/INSTALL_STICKER_PACK';
|
|
payload: Promise<InstallStickerPackPayloadType>;
|
|
};
|
|
type InstallStickerPackFulfilledAction = {
|
|
type: 'stickers/INSTALL_STICKER_PACK_FULFILLED';
|
|
payload: InstallStickerPackPayloadType;
|
|
};
|
|
type ClearInstalledStickerPackAction = {
|
|
type: 'stickers/CLEAR_INSTALLED_STICKER_PACK';
|
|
};
|
|
|
|
type UninstallStickerPackPayloadType = {
|
|
packId: string;
|
|
fromSync: boolean;
|
|
status: 'downloaded';
|
|
installedAt: null;
|
|
recentStickers: Array<RecentStickerType>;
|
|
};
|
|
type UninstallStickerPackAction = {
|
|
type: 'stickers/UNINSTALL_STICKER_PACK';
|
|
payload: Promise<UninstallStickerPackPayloadType>;
|
|
};
|
|
type UninstallStickerPackFulfilledAction = {
|
|
type: 'stickers/UNINSTALL_STICKER_PACK_FULFILLED';
|
|
payload: UninstallStickerPackPayloadType;
|
|
};
|
|
|
|
type StickerPackUpdatedAction = {
|
|
type: 'stickers/STICKER_PACK_UPDATED';
|
|
payload: { packId: string; patch: Partial<StickerPackDBType> };
|
|
};
|
|
|
|
type StickerPackRemovedAction = {
|
|
type: 'stickers/REMOVE_STICKER_PACK';
|
|
payload: string;
|
|
};
|
|
|
|
type UseStickerPayloadType = {
|
|
packId: string;
|
|
stickerId: number;
|
|
time: number;
|
|
};
|
|
type UseStickerAction = {
|
|
type: 'stickers/USE_STICKER';
|
|
payload: Promise<UseStickerPayloadType>;
|
|
};
|
|
type UseStickerFulfilledAction = {
|
|
type: 'stickers/USE_STICKER_FULFILLED';
|
|
payload: UseStickerPayloadType;
|
|
};
|
|
|
|
export type StickersActionType =
|
|
| ClearInstalledStickerPackAction
|
|
| StickerAddedAction
|
|
| StickerPackAddedAction
|
|
| InstallStickerPackFulfilledAction
|
|
| UninstallStickerPackFulfilledAction
|
|
| StickerPackUpdatedAction
|
|
| StickerPackRemovedAction
|
|
| UseStickerFulfilledAction
|
|
| NoopActionType;
|
|
|
|
// Action Creators
|
|
|
|
export const actions = {
|
|
downloadStickerPack,
|
|
clearInstalledStickerPack,
|
|
removeStickerPack,
|
|
stickerAdded,
|
|
stickerPackAdded,
|
|
installStickerPack,
|
|
uninstallStickerPack,
|
|
stickerPackUpdated,
|
|
useSticker,
|
|
};
|
|
|
|
function removeStickerPack(id: string): StickerPackRemovedAction {
|
|
return {
|
|
type: 'stickers/REMOVE_STICKER_PACK',
|
|
payload: id,
|
|
};
|
|
}
|
|
|
|
function stickerAdded(payload: StickerDBType): StickerAddedAction {
|
|
return {
|
|
type: 'stickers/STICKER_ADDED',
|
|
payload,
|
|
};
|
|
}
|
|
|
|
function stickerPackAdded(payload: StickerPackDBType): StickerPackAddedAction {
|
|
const { status, attemptedStatus } = payload;
|
|
|
|
// We do this to trigger a toast, which is still done via Backbone
|
|
if (status === 'error' && attemptedStatus === 'installed') {
|
|
trigger('pack-install-failed');
|
|
}
|
|
|
|
return {
|
|
type: 'stickers/STICKER_PACK_ADDED',
|
|
payload,
|
|
};
|
|
}
|
|
|
|
function downloadStickerPack(
|
|
packId: string,
|
|
packKey: string,
|
|
options?: { finalStatus?: 'installed' | 'downloaded' }
|
|
): NoopActionType {
|
|
const { finalStatus } = options || { finalStatus: undefined };
|
|
|
|
// We're just kicking this off, since it will generate more redux events
|
|
externalDownloadStickerPack(packId, packKey, { finalStatus });
|
|
|
|
return {
|
|
type: 'NOOP',
|
|
payload: null,
|
|
};
|
|
}
|
|
|
|
function installStickerPack(
|
|
packId: string,
|
|
packKey: string,
|
|
options: { fromSync: boolean } | null = null
|
|
): InstallStickerPackAction {
|
|
return {
|
|
type: 'stickers/INSTALL_STICKER_PACK',
|
|
payload: doInstallStickerPack(packId, packKey, options),
|
|
};
|
|
}
|
|
async function doInstallStickerPack(
|
|
packId: string,
|
|
packKey: string,
|
|
options: { fromSync: boolean } | null
|
|
): Promise<InstallStickerPackPayloadType> {
|
|
const { fromSync } = options || { fromSync: false };
|
|
|
|
const status = 'installed';
|
|
const timestamp = Date.now();
|
|
await updateStickerPackStatus(packId, status, { timestamp });
|
|
|
|
if (!fromSync) {
|
|
// Kick this off, but don't wait for it
|
|
sendStickerPackSync(packId, packKey, true);
|
|
}
|
|
|
|
const recentStickers = await getRecentStickers();
|
|
|
|
return {
|
|
packId,
|
|
fromSync,
|
|
status,
|
|
installedAt: timestamp,
|
|
recentStickers: recentStickers.map(item => ({
|
|
packId: item.packId,
|
|
stickerId: item.id,
|
|
})),
|
|
};
|
|
}
|
|
function uninstallStickerPack(
|
|
packId: string,
|
|
packKey: string,
|
|
options: { fromSync: boolean } | null = null
|
|
): UninstallStickerPackAction {
|
|
return {
|
|
type: 'stickers/UNINSTALL_STICKER_PACK',
|
|
payload: doUninstallStickerPack(packId, packKey, options),
|
|
};
|
|
}
|
|
async function doUninstallStickerPack(
|
|
packId: string,
|
|
packKey: string,
|
|
options: { fromSync: boolean } | null
|
|
): Promise<UninstallStickerPackPayloadType> {
|
|
const { fromSync } = options || { fromSync: false };
|
|
|
|
const status = 'downloaded';
|
|
await updateStickerPackStatus(packId, status);
|
|
|
|
// If there are no more references, it should be removed
|
|
await maybeDeletePack(packId);
|
|
|
|
if (!fromSync) {
|
|
// Kick this off, but don't wait for it
|
|
sendStickerPackSync(packId, packKey, false);
|
|
}
|
|
|
|
const recentStickers = await getRecentStickers();
|
|
|
|
return {
|
|
packId,
|
|
fromSync,
|
|
status,
|
|
installedAt: null,
|
|
recentStickers: recentStickers.map(item => ({
|
|
packId: item.packId,
|
|
stickerId: item.id,
|
|
})),
|
|
};
|
|
}
|
|
function clearInstalledStickerPack(): ClearInstalledStickerPackAction {
|
|
return { type: 'stickers/CLEAR_INSTALLED_STICKER_PACK' };
|
|
}
|
|
|
|
function stickerPackUpdated(
|
|
packId: string,
|
|
patch: Partial<StickerPackDBType>
|
|
): StickerPackUpdatedAction {
|
|
const { status, attemptedStatus } = patch;
|
|
|
|
// We do this to trigger a toast, which is still done via Backbone
|
|
if (status === 'error' && attemptedStatus === 'installed') {
|
|
trigger('pack-install-failed');
|
|
}
|
|
|
|
return {
|
|
type: 'stickers/STICKER_PACK_UPDATED',
|
|
payload: {
|
|
packId,
|
|
patch,
|
|
},
|
|
};
|
|
}
|
|
|
|
function useSticker(
|
|
packId: string,
|
|
stickerId: number,
|
|
time = Date.now()
|
|
): UseStickerAction {
|
|
return {
|
|
type: 'stickers/USE_STICKER',
|
|
payload: doUseSticker(packId, stickerId, time),
|
|
};
|
|
}
|
|
async function doUseSticker(
|
|
packId: string,
|
|
stickerId: number,
|
|
time = Date.now()
|
|
): Promise<UseStickerPayloadType> {
|
|
await updateStickerLastUsed(packId, stickerId, time);
|
|
|
|
return {
|
|
packId,
|
|
stickerId,
|
|
time,
|
|
};
|
|
}
|
|
|
|
// Reducer
|
|
|
|
function getEmptyState(): StickersStateType {
|
|
return {
|
|
installedPack: null,
|
|
packs: {},
|
|
recentStickers: [],
|
|
blessedPacks: {},
|
|
};
|
|
}
|
|
|
|
export function reducer(
|
|
state: Readonly<StickersStateType> = getEmptyState(),
|
|
action: Readonly<StickersActionType>
|
|
): StickersStateType {
|
|
if (action.type === 'stickers/STICKER_PACK_ADDED') {
|
|
// ts complains due to `stickers: {}` being overridden by the payload
|
|
// but without full confidence that that's the case, `any` and ignore
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const { payload } = action as any;
|
|
const newPack = {
|
|
stickers: {},
|
|
...payload,
|
|
};
|
|
|
|
return {
|
|
...state,
|
|
packs: {
|
|
...state.packs,
|
|
[payload.id]: newPack,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (action.type === 'stickers/STICKER_ADDED') {
|
|
const { payload } = action;
|
|
const packToUpdate = state.packs[payload.packId];
|
|
|
|
return {
|
|
...state,
|
|
packs: {
|
|
...state.packs,
|
|
[packToUpdate.id]: {
|
|
...packToUpdate,
|
|
stickers: {
|
|
...packToUpdate.stickers,
|
|
[payload.id]: payload,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (action.type === 'stickers/STICKER_PACK_UPDATED') {
|
|
const { payload } = action;
|
|
const packToUpdate = state.packs[payload.packId];
|
|
|
|
return {
|
|
...state,
|
|
packs: {
|
|
...state.packs,
|
|
[packToUpdate.id]: {
|
|
...packToUpdate,
|
|
...payload.patch,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (
|
|
action.type === 'stickers/INSTALL_STICKER_PACK_FULFILLED' ||
|
|
action.type === 'stickers/UNINSTALL_STICKER_PACK_FULFILLED'
|
|
) {
|
|
const { payload } = action;
|
|
const { fromSync, installedAt, packId, status, recentStickers } = payload;
|
|
const { packs } = state;
|
|
const existingPack = packs[packId];
|
|
|
|
// A pack might be deleted as part of the uninstall process
|
|
if (!existingPack) {
|
|
return {
|
|
...state,
|
|
installedPack:
|
|
state.installedPack === packId ? null : state.installedPack,
|
|
recentStickers,
|
|
};
|
|
}
|
|
|
|
const isBlessed = state.blessedPacks[packId];
|
|
const installedPack = !fromSync && !isBlessed ? packId : null;
|
|
|
|
return {
|
|
...state,
|
|
installedPack,
|
|
packs: {
|
|
...packs,
|
|
[packId]: {
|
|
...packs[packId],
|
|
status,
|
|
installedAt,
|
|
},
|
|
},
|
|
recentStickers,
|
|
};
|
|
}
|
|
|
|
if (action.type === 'stickers/CLEAR_INSTALLED_STICKER_PACK') {
|
|
return {
|
|
...state,
|
|
installedPack: null,
|
|
};
|
|
}
|
|
|
|
if (action.type === 'stickers/REMOVE_STICKER_PACK') {
|
|
const { payload } = action;
|
|
|
|
return {
|
|
...state,
|
|
packs: omit(state.packs, payload),
|
|
};
|
|
}
|
|
|
|
if (action.type === 'stickers/USE_STICKER_FULFILLED') {
|
|
const { payload } = action;
|
|
const { packId, stickerId, time } = payload;
|
|
const { recentStickers, packs } = state;
|
|
|
|
const filteredRecents = reject(
|
|
recentStickers,
|
|
item => item.packId === packId && item.stickerId === stickerId
|
|
);
|
|
const pack = packs[packId];
|
|
const sticker = pack.stickers[stickerId];
|
|
|
|
return {
|
|
...state,
|
|
recentStickers: [payload, ...filteredRecents],
|
|
packs: {
|
|
...state.packs,
|
|
[packId]: {
|
|
...pack,
|
|
lastUsed: time,
|
|
stickers: {
|
|
...pack.stickers,
|
|
[stickerId]: {
|
|
...sticker,
|
|
lastUsed: time,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
return state;
|
|
}
|