mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-02-15 07:28:59 +00:00
493 lines
13 KiB
TypeScript
493 lines
13 KiB
TypeScript
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import type { ReadonlyDeep, Simplify } from 'type-fest';
|
|
import type { ThunkAction } from 'redux-thunk';
|
|
|
|
import { useBoundActions } from '../../hooks/useBoundActions.std.js';
|
|
import { createLogger } from '../../logging/log.std.js';
|
|
import * as Errors from '../../types/errors.std.js';
|
|
import { isStagingServer } from '../../util/isStagingServer.dom.js';
|
|
import { DataWriter } from '../../sql/Client.preload.js';
|
|
import * as donations from '../../services/donations.preload.js';
|
|
import {
|
|
donationStateSchema,
|
|
DonationProcessor,
|
|
} from '../../types/Donations.std.js';
|
|
import { drop } from '../../util/drop.std.js';
|
|
import { storageServiceUploadJob } from '../../services/storage.preload.js';
|
|
import { getMe } from '../selectors/conversations.dom.js';
|
|
import { actions as conversationActions } from './conversations.preload.js';
|
|
import type {
|
|
ProfileDataType,
|
|
SetProfileUpdateErrorActionType,
|
|
} from './conversations.preload.js';
|
|
|
|
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.std.js';
|
|
import type {
|
|
CardDetail,
|
|
DonationErrorType,
|
|
DonationReceipt,
|
|
DonationWorkflow,
|
|
OneTimeDonationHumanAmounts,
|
|
StripeDonationAmount,
|
|
} from '../../types/Donations.std.js';
|
|
import type { BadgeType } from '../../badges/types.std.js';
|
|
import type { StateType as RootStateType } from '../reducer.preload.js';
|
|
import { itemStorage } from '../../textsecure/Storage.preload.js';
|
|
import { missingCaseError } from '../../util/missingCaseError.std.js';
|
|
|
|
const log = createLogger('donations');
|
|
|
|
// State
|
|
|
|
export type DonationsStateType = ReadonlyDeep<{
|
|
currentWorkflow: DonationWorkflow | undefined;
|
|
didResumeWorkflowAtStartup: boolean;
|
|
lastError: DonationErrorType | undefined;
|
|
receipts: Array<DonationReceipt>;
|
|
configCache: OneTimeDonationHumanAmounts | undefined;
|
|
}>;
|
|
|
|
// Actions
|
|
|
|
export const ADD_RECEIPT = 'donations/ADD_RECEIPT';
|
|
export const HYDRATE_CONFIG_CACHE = 'donations/HYDRATE_CONFIG_CACHE';
|
|
export const SUBMIT_DONATION = 'donations/SUBMIT_DONATION';
|
|
export const UPDATE_WORKFLOW = 'donations/UPDATE_WORKFLOW';
|
|
export const UPDATE_LAST_ERROR = 'donations/UPDATE_LAST_ERROR';
|
|
export const SET_DID_RESUME = 'donations/SET_DID_RESUME';
|
|
|
|
export type AddReceiptAction = ReadonlyDeep<{
|
|
type: typeof ADD_RECEIPT;
|
|
payload: { receipt: DonationReceipt };
|
|
}>;
|
|
|
|
export type HydrateConfigCacheAction = ReadonlyDeep<{
|
|
type: typeof HYDRATE_CONFIG_CACHE;
|
|
payload: { configCache: OneTimeDonationHumanAmounts };
|
|
}>;
|
|
|
|
export type SetDidResumeAction = ReadonlyDeep<{
|
|
type: typeof SET_DID_RESUME;
|
|
payload: boolean;
|
|
}>;
|
|
|
|
export type SubmitDonationAction = ReadonlyDeep<{
|
|
type: typeof SUBMIT_DONATION;
|
|
payload: SubmitDonationType;
|
|
}>;
|
|
|
|
export type UpdateLastErrorAction = ReadonlyDeep<{
|
|
type: typeof UPDATE_LAST_ERROR;
|
|
payload: { lastError: DonationErrorType | undefined };
|
|
}>;
|
|
|
|
export type UpdateWorkflowAction = ReadonlyDeep<{
|
|
type: typeof UPDATE_WORKFLOW;
|
|
payload: { nextWorkflow: DonationWorkflow | undefined };
|
|
}>;
|
|
|
|
export type DonationsActionType = ReadonlyDeep<
|
|
| AddReceiptAction
|
|
| HydrateConfigCacheAction
|
|
| SetDidResumeAction
|
|
| SubmitDonationAction
|
|
| UpdateLastErrorAction
|
|
| UpdateWorkflowAction
|
|
>;
|
|
|
|
// Action Creators
|
|
|
|
export function addReceipt(receipt: DonationReceipt): AddReceiptAction {
|
|
return {
|
|
type: ADD_RECEIPT,
|
|
payload: { receipt },
|
|
};
|
|
}
|
|
|
|
function internalAddDonationReceipt(
|
|
receipt: DonationReceipt
|
|
): ThunkAction<void, RootStateType, unknown, AddReceiptAction> {
|
|
return async dispatch => {
|
|
if (!isStagingServer()) {
|
|
log.error('internalAddDonationReceipt: Only available on staging server');
|
|
throw new Error('This feature is only available on staging server');
|
|
}
|
|
|
|
try {
|
|
await DataWriter.createDonationReceipt(receipt);
|
|
|
|
dispatch({
|
|
type: ADD_RECEIPT,
|
|
payload: { receipt },
|
|
});
|
|
} catch (error) {
|
|
log.error('Error adding donation receipt', Errors.toLogFormat(error));
|
|
throw error;
|
|
}
|
|
};
|
|
}
|
|
|
|
function hydrateConfigCache(
|
|
configCache: OneTimeDonationHumanAmounts
|
|
): HydrateConfigCacheAction {
|
|
return {
|
|
type: HYDRATE_CONFIG_CACHE,
|
|
payload: { configCache },
|
|
};
|
|
}
|
|
|
|
function setDidResume(didResume: boolean): SetDidResumeAction {
|
|
return {
|
|
type: SET_DID_RESUME,
|
|
payload: didResume,
|
|
};
|
|
}
|
|
|
|
function resumeWorkflow(): ThunkAction<
|
|
void,
|
|
RootStateType,
|
|
unknown,
|
|
SetDidResumeAction
|
|
> {
|
|
return async dispatch => {
|
|
try {
|
|
dispatch({
|
|
type: SET_DID_RESUME,
|
|
payload: false,
|
|
});
|
|
|
|
await donations.resumeDonation();
|
|
} catch (error) {
|
|
log.error('Error resuming workflow', Errors.toLogFormat(error));
|
|
throw error;
|
|
}
|
|
};
|
|
}
|
|
|
|
type SubmitDonationData = ReadonlyDeep<{
|
|
currencyType: string;
|
|
paymentAmount: StripeDonationAmount;
|
|
}>;
|
|
|
|
type SubmitStripeDonationData = ReadonlyDeep<
|
|
SubmitDonationData & {
|
|
paymentDetail: CardDetail;
|
|
}
|
|
>;
|
|
|
|
export type SubmitDonationType = Simplify<
|
|
ReadonlyDeep<
|
|
| ({ processor: DonationProcessor.Stripe } & SubmitStripeDonationData)
|
|
| ({ processor: DonationProcessor.Paypal } & SubmitDonationData)
|
|
>
|
|
>;
|
|
|
|
function submitDonation(
|
|
data: SubmitDonationType
|
|
): ThunkAction<
|
|
void,
|
|
RootStateType,
|
|
unknown,
|
|
UpdateWorkflowAction | UpdateLastErrorAction
|
|
> {
|
|
const { currencyType, paymentAmount, processor } = data;
|
|
|
|
if (processor === DonationProcessor.Stripe) {
|
|
const { paymentDetail } = data;
|
|
return _submitStripeDonation({
|
|
currencyType,
|
|
paymentAmount,
|
|
paymentDetail,
|
|
});
|
|
}
|
|
|
|
if (processor === DonationProcessor.Paypal) {
|
|
return _submitPaypalDonation({
|
|
currencyType,
|
|
paymentAmount,
|
|
});
|
|
}
|
|
|
|
throw missingCaseError(processor);
|
|
}
|
|
|
|
function _submitStripeDonation({
|
|
currencyType,
|
|
paymentAmount,
|
|
paymentDetail,
|
|
}: SubmitStripeDonationData): ThunkAction<
|
|
void,
|
|
RootStateType,
|
|
unknown,
|
|
UpdateWorkflowAction | UpdateLastErrorAction
|
|
> {
|
|
return async (dispatch, getState) => {
|
|
try {
|
|
const { currentWorkflow } = getState().donations;
|
|
if (
|
|
currentWorkflow?.type === donationStateSchema.Enum.INTENT &&
|
|
currentWorkflow.paymentAmount === paymentAmount &&
|
|
currentWorkflow.currencyType === currencyType
|
|
) {
|
|
// we can proceed without starting afresh
|
|
} else {
|
|
await donations.clearDonation();
|
|
await donations.startStripeDonation({
|
|
currencyType,
|
|
paymentAmount,
|
|
});
|
|
}
|
|
|
|
await donations.finishDonationWithCard(paymentDetail);
|
|
} catch (error) {
|
|
log.error('_submitStripeDonation failed', Errors.toLogFormat(error));
|
|
dispatch({
|
|
type: UPDATE_LAST_ERROR,
|
|
payload: { lastError: 'GeneralError' },
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
function _submitPaypalDonation({
|
|
currencyType,
|
|
paymentAmount,
|
|
}: SubmitDonationData): ThunkAction<
|
|
void,
|
|
RootStateType,
|
|
unknown,
|
|
UpdateWorkflowAction | UpdateLastErrorAction
|
|
> {
|
|
return async dispatch => {
|
|
try {
|
|
await donations.clearDonation();
|
|
await donations.startPaypalDonation({
|
|
currencyType,
|
|
paymentAmount,
|
|
});
|
|
} catch (error) {
|
|
log.error('_submitPaypalDonation failed', Errors.toLogFormat(error));
|
|
dispatch({
|
|
type: UPDATE_LAST_ERROR,
|
|
payload: { lastError: 'GeneralError' },
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
function clearWorkflow(): UpdateWorkflowAction {
|
|
drop(donations.clearDonation());
|
|
|
|
return {
|
|
type: UPDATE_WORKFLOW,
|
|
payload: { nextWorkflow: undefined },
|
|
};
|
|
}
|
|
|
|
function updateLastError(
|
|
lastError: DonationErrorType | undefined
|
|
): UpdateLastErrorAction {
|
|
return {
|
|
type: UPDATE_LAST_ERROR,
|
|
payload: { lastError },
|
|
};
|
|
}
|
|
|
|
function updateWorkflow(
|
|
nextWorkflow: DonationWorkflow | undefined
|
|
): UpdateWorkflowAction {
|
|
return {
|
|
type: UPDATE_WORKFLOW,
|
|
payload: { nextWorkflow },
|
|
};
|
|
}
|
|
|
|
export function applyDonationBadge({
|
|
badge,
|
|
applyBadge,
|
|
onComplete,
|
|
storage = itemStorage,
|
|
}: {
|
|
badge: BadgeType | undefined;
|
|
applyBadge: boolean;
|
|
onComplete: (error?: Error) => void;
|
|
|
|
// Only for testing
|
|
storage?: Pick<typeof itemStorage, 'get' | 'put'>;
|
|
}): ThunkAction<void, RootStateType, unknown, SetProfileUpdateErrorActionType> {
|
|
return async (dispatch, getState) => {
|
|
const me = getMe(getState());
|
|
|
|
if (!badge) {
|
|
onComplete(new Error('No badge was given to redeem'));
|
|
return;
|
|
}
|
|
|
|
const allBadgesHaveVisibilityData = me.badges.every(
|
|
myBadge => 'isVisible' in myBadge
|
|
);
|
|
|
|
const desiredBadgeIndexInUserBadges = me.badges.findIndex(
|
|
myBadge => myBadge.id === badge.id
|
|
);
|
|
|
|
const userHasDesiredBadgeToApply = desiredBadgeIndexInUserBadges !== -1;
|
|
const desiredBadgeInUserProfile =
|
|
me.badges?.[desiredBadgeIndexInUserBadges];
|
|
|
|
if (!userHasDesiredBadgeToApply || !desiredBadgeInUserProfile) {
|
|
onComplete(new Error('User does not have the desired badge to apply'));
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!allBadgesHaveVisibilityData ||
|
|
!('isVisible' in desiredBadgeInUserProfile)
|
|
) {
|
|
onComplete(
|
|
new Error("Unable to determine user's existing visible badges")
|
|
);
|
|
return;
|
|
}
|
|
|
|
const previousDisplayBadgesOnProfile =
|
|
me.badges.length > 0 &&
|
|
me.badges.every(myBadge => 'isVisible' in myBadge && myBadge.isVisible);
|
|
|
|
const otherBadges = me.badges?.filter(b => b.id !== badge.id) ?? [];
|
|
|
|
let newDisplayBadgesOnProfile = previousDisplayBadgesOnProfile;
|
|
|
|
if (applyBadge) {
|
|
// Add the badge to the front and make ALL badges visible
|
|
const updatedBadges = [
|
|
{ id: badge.id, isVisible: true },
|
|
...otherBadges.map(b => ({ ...b, isVisible: true })),
|
|
];
|
|
|
|
// Note: We pass only the badges we want visible to myProfileChanged.
|
|
// This is how the API works - we're not "deleting" invisible badges,
|
|
// we're setting the complete list of visible badges.
|
|
const profileData: ProfileDataType = {
|
|
badges: updatedBadges,
|
|
};
|
|
|
|
await dispatch(
|
|
conversationActions.myProfileChanged(profileData, { keepAvatar: true })
|
|
);
|
|
newDisplayBadgesOnProfile = true;
|
|
} else if (
|
|
// If we're here, the user has unchecked the setting to apply the badge.
|
|
// If the badge we want to apply is already the primary visible badge, we
|
|
// disable showing badges.
|
|
// If the user has another badge as primary, we do nothing and keep it.
|
|
desiredBadgeIndexInUserBadges === 0 &&
|
|
desiredBadgeInUserProfile.isVisible
|
|
) {
|
|
const profileData: ProfileDataType = {
|
|
badges: [],
|
|
};
|
|
|
|
await dispatch(
|
|
conversationActions.myProfileChanged(profileData, { keepAvatar: true })
|
|
);
|
|
newDisplayBadgesOnProfile = false;
|
|
}
|
|
|
|
const storageValue = storage.get('displayBadgesOnProfile');
|
|
if (
|
|
storageValue == null ||
|
|
previousDisplayBadgesOnProfile !== newDisplayBadgesOnProfile
|
|
) {
|
|
await storage.put('displayBadgesOnProfile', newDisplayBadgesOnProfile);
|
|
if (previousDisplayBadgesOnProfile !== newDisplayBadgesOnProfile) {
|
|
storageServiceUploadJob({ reason: 'donation-badge-toggle' });
|
|
}
|
|
}
|
|
|
|
onComplete();
|
|
};
|
|
}
|
|
|
|
export const actions = {
|
|
addReceipt,
|
|
applyDonationBadge,
|
|
clearWorkflow,
|
|
internalAddDonationReceipt,
|
|
hydrateConfigCache,
|
|
setDidResume,
|
|
resumeWorkflow,
|
|
submitDonation,
|
|
updateLastError,
|
|
updateWorkflow,
|
|
};
|
|
|
|
export const useDonationsActions = (): BoundActionCreatorsMapObject<
|
|
typeof actions
|
|
> => useBoundActions(actions);
|
|
|
|
// Reducer
|
|
|
|
export function getEmptyState(): DonationsStateType {
|
|
return {
|
|
currentWorkflow: undefined,
|
|
didResumeWorkflowAtStartup: false,
|
|
lastError: undefined,
|
|
receipts: [],
|
|
configCache: undefined,
|
|
};
|
|
}
|
|
|
|
export function reducer(
|
|
state: Readonly<DonationsStateType> = getEmptyState(),
|
|
action: Readonly<DonationsActionType>
|
|
): DonationsStateType {
|
|
if (action.type === ADD_RECEIPT) {
|
|
return {
|
|
...state,
|
|
receipts: [...state.receipts, action.payload.receipt],
|
|
};
|
|
}
|
|
|
|
if (action.type === HYDRATE_CONFIG_CACHE) {
|
|
return {
|
|
...state,
|
|
configCache: action.payload.configCache,
|
|
};
|
|
}
|
|
|
|
if (action.type === SET_DID_RESUME) {
|
|
return {
|
|
...state,
|
|
didResumeWorkflowAtStartup: action.payload,
|
|
};
|
|
}
|
|
|
|
if (action.type === UPDATE_LAST_ERROR) {
|
|
return {
|
|
...state,
|
|
lastError: action.payload.lastError,
|
|
};
|
|
}
|
|
|
|
if (action.type === UPDATE_WORKFLOW) {
|
|
const { nextWorkflow } = action.payload;
|
|
|
|
// If we've cleared the workflow or are starting afresh, we clear the startup flag
|
|
const didResumeWorkflowAtStartup =
|
|
!nextWorkflow || nextWorkflow.type === donationStateSchema.Enum.INTENT
|
|
? false
|
|
: state.didResumeWorkflowAtStartup;
|
|
|
|
return {
|
|
...state,
|
|
didResumeWorkflowAtStartup,
|
|
currentWorkflow: nextWorkflow,
|
|
};
|
|
}
|
|
|
|
return state;
|
|
}
|