Start Donations Receipt Flow UI

This commit is contained in:
yash-signal 2025-07-07 18:53:46 -05:00 committed by GitHub
parent b04d3a9c7b
commit 70162be74e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 994 additions and 68 deletions

View file

@ -816,6 +816,14 @@
"messageformat": "Failed to process some frames during backup import. Please share your logs.",
"description": "[Only shown to internal users] An error popup when we failed to process some parts of a backup import."
},
"icu:Toast--ReceiptSaved": {
"messageformat": "Receipt saved",
"description": "Toast message shown when a donation receipt has been successfully saved to disk"
},
"icu:Toast--ReceiptSaveFailed": {
"messageformat": "Error saving receipt. Please try again.",
"description": "Toast message shown when a donation receipt fails to save to disk"
},
"icu:cannotSelectPhotosAndVideosAlongWithFiles": {
"messageformat": "You can't select photos and videos along with files.",
"description": "An error popup when the user has attempted to add an attachment"
@ -8802,29 +8810,82 @@
"messageformat": "What's New",
"description": "Title for the whats new modal"
},
"icu:PreferencesDonations__title": {
"messageformat": "Privacy over Profit",
"description": "Title shown at the top of the donations preferences page"
},
"icu:PreferencesDonations__description": {
"messageformat": "Private messaging, funded by you. No ads, no tracking, no compromise. Donate now to support Signal. <learnMoreLink>Learn more</learnMoreLink>",
"description": "Description text explaining Signal's donation model with learn more link"
},
"icu:PreferencesDonations__donate-button": {
"messageformat": "Donate",
"description": "Button text to make a donation"
},
"icu:PreferencesDonations__mobile-info": {
"messageformat": "Badges and monthly donations can be managed on your mobile device.",
"description": "Information about donations receipt syncing limitations"
},
"icu:PreferencesDonations__receipts": {
"messageformat": "Receipts",
"description": "Menu item to view donation receipts"
},
"icu:PreferencesDonations__faqs": {
"messageformat": "Donation FAQs",
"description": "Menu item to view donation FAQs"
},
"icu:PreferencesDonations--receiptList__info": {
"messageformat": "Receipts do not sync across devices. If you have reinstalled Signal, receipts from previous donations will not be available.",
"description": "Information about donation receipts syncing limitations"
},
"icu:PreferencesDonations--receiptList__empty-title": {
"messageformat": "No Receipts",
"description": "Title shown when there are no donation receipts"
},
"icu:PreferencesDonations__ReceiptModal--title": {
"messageformat": "Details",
"description": "Title of the donation receipt details modal"
},
"icu:PreferencesDonations__ReceiptModal--download": {
"messageformat": "Download receipt",
"description": "Button text to download a donation receipt"
},
"icu:PreferencesDonations__ReceiptModal--type-label": {
"messageformat": "Type",
"description": "Label for the donation type in donation receipt modal"
},
"icu:PreferencesDonations__ReceiptModal--date-paid-label": {
"messageformat": "Date paid",
"description": "Label for the payment date in donation receipt modal"
},
"icu:DonationReceipt__title": {
"messageformat": "Donation receipt",
"description": "Title shown at the top of donation receipt documents"
},
"icu:DonationReceipt__amount-label": {
"messageformat": "Amount",
"description": "Label for the donation amount field on receipt"
"description": "Label for the donation amount field on donation receipt"
},
"icu:DonationReceipt__type-label": {
"messageformat": "Type",
"description": "Label for the donation type field on receipt"
"description": "Label for the donation type field on donation receipt"
},
"icu:DonationReceipt__date-paid-label": {
"messageformat": "Date paid",
"description": "Label for the payment date field on receipt"
"description": "Label for the payment date field on donation receipt"
},
"icu:DonationReceipt__type-value--one-time": {
"messageformat": "One-time",
"description": "Value shown for one-time donations on receipt"
},
"icu:DonationReceipt__payment-method-label": {
"messageformat": "Payment method",
"description": "(Deleted 2025/07/02) Label for the payment method field on receipt"
"messageformat": "One time",
"description": "Value shown for one-time donations on donation receipt"
},
"icu:DonationReceipt__footer-text": {
"messageformat": "Thank you for supporting Signal. Your contribution helps fuel the mission of protecting free expression and enabling secure global communication for millions around the world, through open source privacy technology. If youre a resident of the United States, please retain this receipt for your tax records. Signal Technology Foundation is a tax-exempt nonprofit organization in the United States under section 501c3 of the Internal Revenue Code. Our Federal Tax ID is 82-4506840.",

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.667 5.625a.625.625 0 1 0 0 1.25h6.666a.625.625 0 1 0 0-1.25H6.667Zm-.625 3.542c0-.345.28-.625.625-.625h6.666a.625.625 0 1 1 0 1.25H6.667a.625.625 0 0 1-.625-.625Zm.625 3.541a.625.625 0 0 1 0-1.25h4.166a.625.625 0 1 1 0 1.25H6.667Z" fill="#000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M7.053 1.354c-.558 0-1.02 0-1.397.031-.393.032-.757.101-1.1.276-.529.27-.959.7-1.229 1.229-.174.343-.243.706-.275 1.099-.031.377-.031.84-.031 1.398v10.09c0 .25 0 .487.055.717.048.202.128.395.236.572.124.202.292.37.469.546l1.537 1.537a.73.73 0 0 0 1.03 0l1.569-1.568 1.567 1.568a.73.73 0 0 0 1.032 0l1.567-1.568 1.568 1.568a.73.73 0 0 0 1.031 0l1.537-1.537c.177-.177.345-.344.468-.546a1.95 1.95 0 0 0 .237-.572c.056-.23.056-.467.055-.717V5.387c0-.558 0-1.02-.03-1.398-.033-.393-.102-.756-.276-1.1-.27-.528-.7-.959-1.23-1.228-.342-.175-.706-.244-1.099-.276-.377-.03-.84-.03-1.397-.03H7.053ZM5.22 2.96c.102-.052.253-.097.555-.122.311-.025.714-.025 1.31-.025h5.833c.595 0 .998 0 1.309.025.302.025.453.07.555.122.255.13.462.337.592.592.053.103.097.253.122.556.025.31.026.713.026 1.309v10.005c0 .34-.005.391-.015.432a.52.52 0 0 1-.062.15c-.022.036-.055.075-.295.316l-.982.982-1.568-1.568a.73.73 0 0 0-1.031 0L10 17.302l-1.568-1.568a.73.73 0 0 0-1.03 0l-1.569 1.568-.982-.982c-.24-.24-.273-.28-.295-.316a.52.52 0 0 1-.062-.15c-.01-.04-.015-.092-.015-.432V5.417c0-.596 0-.998.026-1.31.025-.302.07-.452.122-.555.13-.255.337-.462.592-.592Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -57,6 +57,7 @@ $color-white-alpha-55: rgba($color-white, 0.55);
$color-white-alpha-60: rgba($color-white, 0.6);
$color-white-alpha-70: rgba($color-white, 0.7);
$color-white-alpha-80: rgba($color-white, 0.8);
$color-white-alpha-85: rgba($color-white, 0.85);
$color-white-alpha-90: rgba($color-white, 0.9);
$color-black-alpha-05: rgba($color-black, 0.05);
@ -75,6 +76,7 @@ $color-black-alpha-50: rgba($color-black, 0.5);
$color-black-alpha-60: rgba($color-black, 0.6);
$color-black-alpha-70: rgba($color-black, 0.7);
$color-black-alpha-80: rgba($color-black, 0.8);
$color-black-alpha-85: rgba($color-black, 0.85);
$color-black-alpha-90: rgba($color-black, 0.9);
$color-transparent: rgba(0, 0, 0, 0);

View file

@ -0,0 +1,341 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
@use '../mixins';
@use '../variables';
.PreferencesDonations {
display: flex;
flex-direction: column;
align-items: center;
padding-block: 0;
padding-inline: 0;
margin-inline-start: 24px;
margin-inline-end: 24px;
&__title {
@include mixins.font-title-medium;
margin-bottom: 16px;
}
&__description {
@include mixins.font-body-2;
text-align: center;
max-width: 320px;
margin-bottom: 24px;
color: light-dark(
variables.$color-black-alpha-50,
variables.$color-white-alpha-50
);
&__read-more {
@include mixins.button-reset;
& {
color: variables.$color-ultramarine;
}
&:hover {
text-decoration: underline;
}
}
}
&__donate-button {
margin-bottom: 24px;
}
&__separator {
width: 100%;
height: 0.5px;
border: none;
margin: 0;
background-color: light-dark(
variables.$color-black-alpha-12,
variables.$color-white-alpha-12
);
}
&__section-header {
@include mixins.font-body-2-bold;
width: 100%;
margin-top: 24px;
margin-bottom: 8px;
color: light-dark(
variables.$color-black-alpha-85,
variables.$color-white-alpha-85
);
}
&__list {
margin-top: 24px;
width: 100%;
}
&__list-item {
@include mixins.button-reset;
& {
display: flex;
width: 100%;
align-items: center;
gap: 12px;
padding-block: 12px;
padding-inline: 24px;
border-radius: 5px;
}
&:hover {
background: light-dark(
variables.$color-gray-02,
variables.$color-gray-80
);
}
@include mixins.keyboard-mode {
&:focus {
outline: 2px solid variables.$color-ultramarine;
}
}
&__icon {
width: 20px;
height: 20px;
&--receipts::before {
content: '';
display: block;
width: 20px;
height: 20px;
@include mixins.color-svg(
'../images/icons/v3/receipt/receipt.svg',
light-dark(variables.$color-gray-75, variables.$color-gray-15)
);
}
&--faqs::before {
content: '';
display: block;
width: 20px;
height: 20px;
@include mixins.color-svg(
'../images/icons/v3/help/help-light.svg',
light-dark(variables.$color-gray-75, variables.$color-gray-15)
);
}
}
&__text {
@include mixins.font-body-1;
flex: 1;
color: light-dark(variables.$color-gray-90, variables.$color-gray-05);
}
&__chevron {
&::before {
content: '';
display: block;
width: 20px;
height: 20px;
@include mixins.color-svg(
'../images/icons/v3/chevron/chevron-right.svg',
light-dark(variables.$color-gray-45, variables.$color-gray-25)
);
}
}
}
}
// Receipts page specific styles
.PreferencesDonations--receiptList {
&__info {
margin-inline: 24px;
margin-bottom: 24px;
&__text {
@include mixins.font-subtitle;
color: light-dark(
variables.$color-black-alpha-50,
variables.$color-white-alpha-50
);
}
}
&-yearContainer {
width: 100%;
}
&__year-header {
@include mixins.font-body-2-bold;
color: light-dark(
variables.$color-black-alpha-85,
variables.$color-white-alpha-85
);
padding-block: 8px;
padding-inline: 24px;
background-color: light-dark(
variables.$color-white,
variables.$color-gray-95
);
}
&__list {
width: 100%;
}
&__receipt-item {
@include mixins.button-reset;
& {
display: flex;
align-items: center;
gap: 12px;
padding-block: 8px;
padding-inline: 24px;
border-radius: 5px;
width: 100%;
}
&:hover {
background-color: light-dark(
variables.$color-gray-02,
variables.$color-gray-80
);
}
// Placeholder for icon depending on receipt type
&__icon {
width: 36px;
height: 36px;
border-radius: 18px;
background-color: variables.$color-ultramarine-pale;
flex-shrink: 0;
}
&__details {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
&__date {
@include mixins.font-body-1;
color: light-dark(
variables.$color-black-alpha-85,
variables.$color-white-alpha-85
);
}
&__type {
@include mixins.font-subtitle;
color: light-dark(
variables.$color-black-alpha-50,
variables.$color-white-alpha-50
);
}
&__amount {
@include mixins.font-body-1;
color: light-dark(
variables.$color-black-alpha-50,
variables.$color-white-alpha-50
);
}
}
&__empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 8px;
margin: auto;
&__title {
@include mixins.font-body-2;
color: light-dark(
variables.$color-black-alpha-50,
variables.$color-white-alpha-50
);
}
&__description {
@include mixins.font-caption;
color: light-dark(
variables.$color-black-alpha-50,
variables.$color-white-alpha-50
);
max-width: 300px;
}
}
}
.PreferencesDonations__ReceiptModal {
&__content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
&__logo-container {
margin-bottom: 16px;
}
&__logo {
width: 100px;
height: 28.571px;
margin-bottom: 24px;
background-image: url('../images/signal-logo-and-wordmark.svg');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
&__amount {
font-size: 40px;
letter-spacing: 0.07px;
color: light-dark(variables.$color-gray-90, variables.$color-gray-05);
margin-bottom: 24px;
}
&__separator {
width: 100%;
height: 0.5px;
border: none;
margin: 0;
background-color: light-dark(
variables.$color-black-alpha-12,
variables.$color-white-alpha-12
);
}
&__details {
width: 100%;
text-align: start;
}
&__detail-item {
padding-block: 10px;
padding-inline: 0;
}
&__detail-label {
@include mixins.font-body-1;
color: light-dark(
variables.$color-black-alpha-85,
variables.$color-white-alpha-85
);
margin-bottom: 2px;
}
&__detail-value {
@include mixins.font-subtitle;
color: light-dark(
variables.$color-black-alpha-50,
variables.$color-white-alpha-50
);
}
}

View file

@ -144,6 +144,7 @@
@use 'components/PlaybackButton.scss';
@use 'components/PlaybackRateButton.scss';
@use 'components/Preferences.scss';
@use 'components/PreferencesDonations.scss';
@use 'components/ProfileEditor.scss';
@use 'components/ProfileMovedModal.scss';
@use 'components/ProfileNameWarningModal.scss';

View file

@ -15,6 +15,7 @@ import { DAY, DurationInSeconds, WEEK } from '../util/durations';
import { DialogUpdate } from './DialogUpdate';
import { DialogType } from '../types/Dialogs';
import { ThemeType } from '../types/Util';
import type { LocalizerType } from '../types/Util';
import {
getDefaultConversation,
getDefaultGroup,
@ -30,6 +31,8 @@ import type { WidthBreakpoint } from './_util';
import type { MessageAttributesType } from '../model-types';
import { PreferencesDonations } from './PreferencesDonations';
import { strictAssert } from '../util/assert';
import type { DonationReceipt } from '../types/Donations';
import type { AnyToast } from '../types/Toast';
const { i18n } = window.SignalContext;
@ -168,7 +171,20 @@ function RenderProfileEditor(): JSX.Element {
);
}
function RenderDonationsPane(): JSX.Element {
function RenderDonationsPane(props: {
me: typeof me;
donationReceipts: ReadonlyArray<DonationReceipt>;
saveAttachmentToDisk: (options: {
data: Uint8Array;
name: string;
baseDir?: string | undefined;
}) => Promise<{ fullPath: string; name: string } | null>;
generateDonationReceiptBlob: (
receipt: DonationReceipt,
i18n: LocalizerType
) => Promise<Blob>;
showToast: (toast: AnyToast) => void;
}): JSX.Element {
const contentsRef = useRef<HTMLDivElement | null>(null);
return (
<PreferencesDonations
@ -180,6 +196,14 @@ function RenderDonationsPane(): JSX.Element {
setPage={action('setPage')}
submitDonation={action('submitDonation')}
workflow={undefined}
userAvatarData={[]}
color={props.me.color}
firstName={props.me.firstName}
profileAvatarUrl={props.me.profileAvatarUrl}
donationReceipts={props.donationReceipts}
saveAttachmentToDisk={props.saveAttachmentToDisk}
generateDonationReceiptBlob={props.generateDonationReceiptBlob}
showToast={props.showToast}
/>
);
}
@ -302,7 +326,20 @@ export default {
whoCanSeeMe: PhoneNumberSharingMode.Everybody,
zoomFactor: 1,
renderDonationsPane: RenderDonationsPane,
renderDonationsPane: () =>
RenderDonationsPane({
me,
donationReceipts: [],
saveAttachmentToDisk: async () => {
action('saveAttachmentToDisk')();
return { fullPath: '/mock/path/to/file.png', name: 'file.png' };
},
generateDonationReceiptBlob: async () => {
action('generateDonationReceiptBlob')();
return new Blob();
},
showToast: action('showToast'),
}),
renderProfileEditor: RenderProfileEditor,
renderToastManager,
renderUpdateDialog,

View file

@ -336,6 +336,7 @@ export enum Page {
ChatColor = 'ChatColor',
ChatFolders = 'ChatFolders',
DonationsDonateFlow = 'DonationsDonateFlow',
DonationsReceiptList = 'DonationsReceiptList',
EditChatFolder = 'EditChatFolder',
PNP = 'PNP',
BackupsDetails = 'BackupsDetails',
@ -345,6 +346,14 @@ export enum Page {
LocalBackupsKeyReference = 'LocalBackupsKeyReference',
}
function isDonationsPage(page: Page): boolean {
return (
page === Page.Donations ||
page === Page.DonationsDonateFlow ||
page === Page.DonationsReceiptList
);
}
enum LanguageDialog {
Selection,
Confirmation,
@ -607,10 +616,7 @@ export function Preferences({
if (page === Page.Backups && !shouldShowBackupsPage) {
setPage(Page.General);
}
if (
(page === Page.Donations || page === Page.DonationsDonateFlow) &&
!donationsFeatureEnabled
) {
if (isDonationsPage(page) && !donationsFeatureEnabled) {
setPage(Page.General);
}
if (page === Page.Internal && !isInternalUser) {
@ -908,7 +914,7 @@ export function Preferences({
title={i18n('icu:Preferences__button--general')}
/>
);
} else if (page === Page.Donations || page === Page.DonationsDonateFlow) {
} else if (isDonationsPage(page)) {
content = renderDonationsPane({
contentsRef: settingsPaneRef,
page,
@ -2380,9 +2386,7 @@ export function Preferences({
className={classNames({
Preferences__button: true,
'Preferences__button--appearance': true,
'Preferences__button--selected':
page === Page.Donations ||
page === Page.DonationsDonateFlow,
'Preferences__button--selected': isDonationsPage(page),
})}
onClick={() => setPage(Page.Donations)}
>

View file

@ -3,19 +3,12 @@
import React, { useCallback, useRef, useState } from 'react';
import type { MutableRefObject } from 'react';
import type { LocalizerType } from '../types/Util';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
import { PreferencesContent } from './Preferences';
import { Button, ButtonVariant } from './Button';
import type { CardDetail, DonationWorkflow } from '../types/Donations';
import { Input } from './Input';
type PropsExternalType = {
contentsRef: MutableRefObject<HTMLDivElement | null>;
};
export type PropsDataType = {
i18n: LocalizerType;
workflow: DonationWorkflow | undefined;
@ -23,7 +16,6 @@ export type PropsDataType = {
type PropsActionType = {
clearWorkflow: () => void;
onBack: () => void;
submitDonation: (options: {
currencyType: string;
paymentAmount: number;
@ -31,14 +23,12 @@ type PropsActionType = {
}) => void;
};
export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
export type PropsType = PropsDataType & PropsActionType;
export function PreferencesDonateFlow({
contentsRef,
i18n,
workflow,
clearWorkflow,
onBack,
submitDonation,
}: PropsType): JSX.Element {
const tryClose = useRef<() => void | undefined>();
@ -81,15 +71,6 @@ export function PreferencesDonateFlow({
const isDonateDisabled = workflow !== undefined;
const backButton = (
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={onBack}
type="button"
/>
);
const onTryClose = useCallback(() => {
const onDiscard = () => {
// TODO: DESKTOP-8950
@ -165,13 +146,7 @@ export function PreferencesDonateFlow({
return (
<>
{confirmDiscardModal}
<PreferencesContent
backButton={backButton}
contents={content}
contentsRef={contentsRef}
title={i18n('icu:Preferences__DonateTitle')}
/>
{content}
</>
);
}

View file

@ -1,15 +1,34 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { groupBy, sortBy } from 'lodash';
import type { MutableRefObject } from 'react';
import type { MutableRefObject, ReactNode } from 'react';
import { ListBox, ListBoxItem } from 'react-aria-components';
import { getDateTimeFormatter } from '../util/formatTimestamp';
import type { LocalizerType } from '../types/Util';
import { Page, PreferencesContent } from './Preferences';
import { Button, ButtonVariant } from './Button';
import { PreferencesDonateFlow } from './PreferencesDonateFlow';
import type { CardDetail, DonationWorkflow } from '../types/Donations';
import type {
CardDetail,
DonationWorkflow,
DonationReceipt,
} from '../types/Donations';
import type { AvatarColorType } from '../types/Colors';
import type { AvatarDataType } from '../types/Avatar';
import { AvatarPreview } from './AvatarPreview';
import { Button, ButtonSize, ButtonVariant } from './Button';
import { Modal } from './Modal';
import { Spinner } from './Spinner';
import type { AnyToast } from '../types/Toast';
import { ToastType } from '../types/Toast';
import { createLogger } from '../logging/log';
import { toLogFormat } from '../types/errors';
import { I18n } from './I18n';
const log = createLogger('PreferencesDonations');
type PropsExternalType = {
contentsRef: MutableRefObject<HTMLDivElement | null>;
@ -20,6 +39,21 @@ export type PropsDataType = {
isStaging: boolean;
page: Page;
workflow: DonationWorkflow | undefined;
userAvatarData: ReadonlyArray<AvatarDataType>;
color?: AvatarColorType;
firstName?: string;
profileAvatarUrl?: string;
donationReceipts: ReadonlyArray<DonationReceipt>;
saveAttachmentToDisk: (options: {
data: Uint8Array;
name: string;
baseDir?: string | undefined;
}) => Promise<{ fullPath: string; name: string } | null>;
generateDonationReceiptBlob: (
receipt: DonationReceipt,
i18n: LocalizerType
) => Promise<Blob>;
showToast: (toast: AnyToast) => void;
};
type PropsActionType = {
@ -34,6 +68,335 @@ type PropsActionType = {
export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
type DonationPage =
| Page.Donations
| Page.DonationsDonateFlow
| Page.DonationsReceiptList;
type PreferencesHomeProps = PropsType & {
navigateToPage: (newPage: Page) => void;
};
function isDonationPage(page: Page): page is DonationPage {
return (
page === Page.Donations ||
page === Page.DonationsDonateFlow ||
page === Page.DonationsReceiptList
);
}
function LearnMoreButton(parts: ReactNode): JSX.Element {
return (
<button
type="button"
className="PreferencesDonations__description__read-more"
onClick={() => {
// DESKTOP-8973
}}
>
{parts}
</button>
);
}
function DonationsHome({
i18n,
userAvatarData,
color,
firstName,
profileAvatarUrl,
navigateToPage,
setPage,
isStaging,
donationReceipts,
}: PreferencesHomeProps): JSX.Element {
const avatarData = userAvatarData[0];
const avatarBuffer = avatarData?.buffer;
const hasReceipts = donationReceipts.length > 0;
return (
<div className="PreferencesDonations">
<div className="PreferencesDonations__avatar">
<AvatarPreview
avatarColor={color}
avatarUrl={profileAvatarUrl}
avatarValue={avatarBuffer}
conversationTitle={firstName || i18n('icu:unknownContact')}
i18n={i18n}
style={{
height: 80,
width: 80,
}}
/>
</div>
<div className="PreferencesDonations__title">
{i18n('icu:PreferencesDonations__title')}
</div>
<div className="PreferencesDonations__description">
<I18n
components={{
learnMoreLink: LearnMoreButton,
}}
i18n={i18n}
id="icu:PreferencesDonations__description"
/>
</div>
{isStaging && (
<Button
className="PreferencesDonations__donate-button"
variant={ButtonVariant.Primary}
size={ButtonSize.Medium}
onClick={() => {
setPage(Page.DonationsDonateFlow);
}}
>
{i18n('icu:PreferencesDonations__donate-button')}
</Button>
)}
<hr className="PreferencesDonations__separator" />
<ListBox className="PreferencesDonations__list">
{hasReceipts && (
<ListBoxItem
className="PreferencesDonations__list-item"
onAction={() => {
navigateToPage(Page.DonationsReceiptList);
}}
>
<span className="PreferencesDonations__list-item__icon PreferencesDonations__list-item__icon--receipts" />
<span className="PreferencesDonations__list-item__text">
{i18n('icu:PreferencesDonations__receipts')}
</span>
<span className="PreferencesDonations__list-item__chevron" />
</ListBoxItem>
)}
<ListBoxItem
className="PreferencesDonations__list-item"
onAction={() => {
// TODO: Handle donation FAQs action
}}
>
<span className="PreferencesDonations__list-item__icon PreferencesDonations__list-item__icon--faqs" />
<span className="PreferencesDonations__list-item__text">
{i18n('icu:PreferencesDonations__faqs')}
</span>
<span className="PreferencesDonations__list-item__chevron" />
</ListBoxItem>
</ListBox>
</div>
);
}
function PreferencesReceiptList({
i18n,
donationReceipts,
saveAttachmentToDisk,
generateDonationReceiptBlob,
showToast,
}: {
i18n: LocalizerType;
donationReceipts: ReadonlyArray<DonationReceipt>;
saveAttachmentToDisk: (options: {
data: Uint8Array;
name: string;
baseDir?: string | undefined;
}) => Promise<{ fullPath: string; name: string } | null>;
generateDonationReceiptBlob: (
receipt: DonationReceipt,
i18n: LocalizerType
) => Promise<Blob>;
showToast: (toast: AnyToast) => void;
}): JSX.Element {
const [showReceiptModal, setShowReceiptModal] = useState(false);
const [selectedReceipt, setSelectedReceipt] =
useState<DonationReceipt | null>(null);
const [isDownloading, setIsDownloading] = useState(false);
const sortedReceipts = sortBy(
donationReceipts,
receipt => -receipt.timestamp
);
const receiptsByYear = groupBy(sortedReceipts, receipt =>
new Date(receipt.timestamp).getFullYear()
);
const dateFormatter = getDateTimeFormatter({
month: 'short',
day: 'numeric',
year: 'numeric',
});
const preferredSystemLocales =
window.SignalContext.getPreferredSystemLocales();
const localeOverride = window.SignalContext.getLocaleOverride();
const locales =
localeOverride != null ? [localeOverride] : preferredSystemLocales;
const getCurrencyFormatter = (currencyType: string) =>
new Intl.NumberFormat(locales, {
style: 'currency',
currency: currencyType,
});
const hasReceipts = Object.keys(receiptsByYear).length > 0;
const handleDownloadReceipt = useCallback(async () => {
if (!selectedReceipt) {
return;
}
setIsDownloading(true);
try {
const blob = await generateDonationReceiptBlob(selectedReceipt, i18n);
const buffer = await blob.arrayBuffer();
const result = await saveAttachmentToDisk({
name: `Signal_Receipt_${new Date(selectedReceipt.timestamp).toISOString().split('T')[0]}.png`,
data: new Uint8Array(buffer),
});
if (result) {
setShowReceiptModal(false);
showToast({
toastType: ToastType.ReceiptSaved,
parameters: { fullPath: result.fullPath },
});
}
} catch (error) {
log.error('Failed to generate receipt: ', toLogFormat(error));
showToast({
toastType: ToastType.ReceiptSaveFailed,
});
} finally {
setIsDownloading(false);
}
}, [
selectedReceipt,
generateDonationReceiptBlob,
i18n,
saveAttachmentToDisk,
showToast,
]);
return (
<div className="PreferencesDonations PreferencesDonations--receiptList">
{hasReceipts ? (
<>
<div className="PreferencesDonations--receiptList__info">
<div className="PreferencesDonations--receiptList__info__text">
{i18n('icu:PreferencesDonations--receiptList__info')}
</div>
</div>
{Object.entries(receiptsByYear).map(([year, receipts]) => (
<div
key={year}
className="PreferencesDonations--receiptList-yearContainer"
>
<div className="PreferencesDonations--receiptList__year-header">
{year}
</div>
<ListBox className="PreferencesDonations--receiptList__list">
{receipts.map(receipt => (
<ListBoxItem
key={receipt.id}
className="PreferencesDonations--receiptList__receipt-item"
onAction={() => {
setSelectedReceipt(receipt);
setShowReceiptModal(true);
}}
>
<div className="PreferencesDonations--receiptList__receipt-item__icon" />
<div className="PreferencesDonations--receiptList__receipt-item__details">
<div className="PreferencesDonations--receiptList__receipt-item__date">
{dateFormatter.format(new Date(receipt.timestamp))}
</div>
<div className="PreferencesDonations--receiptList__receipt-item__type">
{i18n('icu:DonationReceipt__type-value--one-time')}
</div>
</div>
<div className="PreferencesDonations--receiptList__receipt-item__amount">
{getCurrencyFormatter(receipt.currencyType).format(
receipt.paymentAmount / 100
)}
</div>
</ListBoxItem>
))}
</ListBox>
</div>
))}
</>
) : (
<div className="PreferencesDonations--receiptList__empty-state">
<div className="PreferencesDonations--receiptList__empty-state__title">
{i18n('icu:PreferencesDonations--receiptList__empty-title')}
</div>
<div className="PreferencesDonations--receiptList__empty-state__description">
{i18n('icu:PreferencesDonations--receiptList__info')}
</div>
</div>
)}
{showReceiptModal && selectedReceipt && (
<Modal
i18n={i18n}
modalName="ReceiptDetailsModal"
moduleClassName="PreferencesDonations__ReceiptModal"
hasXButton
title={i18n('icu:PreferencesDonations__ReceiptModal--title')}
onClose={() => setShowReceiptModal(false)}
modalFooter={
<Button
variant={ButtonVariant.Primary}
onClick={handleDownloadReceipt}
disabled={isDownloading}
>
{isDownloading ? (
<Spinner size="24px" svgSize="small" />
) : (
i18n('icu:PreferencesDonations__ReceiptModal--download')
)}
</Button>
}
>
<div className="PreferencesDonations__ReceiptModal__content">
<div className="PreferencesDonations__ReceiptModal__logo-container">
<div className="PreferencesDonations__ReceiptModal__logo" />
</div>
<div className="PreferencesDonations__ReceiptModal__amount">
{getCurrencyFormatter(selectedReceipt.currencyType).format(
selectedReceipt.paymentAmount / 100
)}
</div>
<hr className="PreferencesDonations__ReceiptModal__separator" />
<div className="PreferencesDonations__ReceiptModal__details">
<div className="PreferencesDonations__ReceiptModal__detail-item">
<div className="PreferencesDonations__ReceiptModal__detail-label">
{i18n('icu:PreferencesDonations__ReceiptModal--type-label')}
</div>
<div className="PreferencesDonations__ReceiptModal__detail-value">
{i18n('icu:DonationReceipt__type-value--one-time')}
</div>
</div>
<div className="PreferencesDonations__ReceiptModal__detail-item">
<div className="PreferencesDonations__ReceiptModal__detail-label">
{i18n(
'icu:PreferencesDonations__ReceiptModal--date-paid-label'
)}
</div>
<div className="PreferencesDonations__ReceiptModal__detail-value">
{dateFormatter.format(new Date(selectedReceipt.timestamp))}
</div>
</div>
</div>
</div>
</Modal>
)}
</div>
);
}
export function PreferencesDonations({
contentsRef,
i18n,
@ -43,38 +406,119 @@ export function PreferencesDonations({
clearWorkflow,
setPage,
submitDonation,
}: PropsType): JSX.Element {
userAvatarData,
color,
firstName,
profileAvatarUrl,
donationReceipts,
saveAttachmentToDisk,
generateDonationReceiptBlob,
showToast,
}: PropsType): JSX.Element | null {
const PAGE_CONFIG = useMemo<
Record<DonationPage, { title: string | undefined; goBackTo: Page | null }>
>(() => {
return {
[Page.Donations]: {
title: i18n('icu:Preferences__DonateTitle'),
goBackTo: null,
},
[Page.DonationsReceiptList]: {
title: i18n('icu:PreferencesDonations__receipts'),
goBackTo: Page.Donations,
},
[Page.DonationsDonateFlow]: {
title: undefined,
goBackTo: Page.Donations,
},
} as const;
}, [i18n]);
const navigateToPage = useCallback(
(newPage: Page) => {
setPage(newPage);
},
[setPage]
);
const handleBack = useCallback(() => {
if (!isDonationPage(page)) {
log.error(
'Donations page back button tried to go to a non-donations page, ignoring'
);
return;
}
const { goBackTo } = PAGE_CONFIG[page];
if (goBackTo) {
setPage(goBackTo);
}
}, [PAGE_CONFIG, page, setPage]);
if (!isDonationPage(page)) {
return null;
}
let content;
if (page === Page.DonationsDonateFlow) {
return (
content = (
<PreferencesDonateFlow
contentsRef={contentsRef}
i18n={i18n}
workflow={workflow}
clearWorkflow={clearWorkflow}
onBack={() => setPage(Page.Donations)}
submitDonation={submitDonation}
/>
);
}
if (page === Page.Donations) {
content = (
<DonationsHome
contentsRef={contentsRef}
i18n={i18n}
userAvatarData={userAvatarData}
color={color}
firstName={firstName}
profileAvatarUrl={profileAvatarUrl}
navigateToPage={navigateToPage}
donationReceipts={donationReceipts}
saveAttachmentToDisk={saveAttachmentToDisk}
generateDonationReceiptBlob={generateDonationReceiptBlob}
showToast={showToast}
isStaging={isStaging}
page={page}
workflow={workflow}
clearWorkflow={clearWorkflow}
setPage={setPage}
submitDonation={submitDonation}
/>
);
} else if (page === Page.DonationsReceiptList) {
content = (
<PreferencesReceiptList
i18n={i18n}
donationReceipts={donationReceipts}
saveAttachmentToDisk={saveAttachmentToDisk}
generateDonationReceiptBlob={generateDonationReceiptBlob}
showToast={showToast}
/>
);
}
const content = (
<div className="PreferencesDonations">
{isStaging && (
<Button
onClick={() => setPage(Page.DonationsDonateFlow)}
variant={ButtonVariant.Primary}
>
Donate
</Button>
)}
</div>
);
// Show back button based on page configuration
const backButton = PAGE_CONFIG[page].goBackTo ? (
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={handleBack}
type="button"
/>
) : undefined;
return (
<PreferencesContent
backButton={backButton}
contents={content}
contentsRef={contentsRef}
title={i18n('icu:Preferences__DonateTitle')}
title={PAGE_CONFIG[page].title}
/>
);
}

View file

@ -150,6 +150,13 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.PinnedConversationsFull };
case ToastType.ReactionFailed:
return { toastType: ToastType.ReactionFailed };
case ToastType.ReceiptSaved:
return {
toastType: ToastType.ReceiptSaved,
parameters: { fullPath: '/image.png' },
};
case ToastType.ReceiptSaveFailed:
return { toastType: ToastType.ReceiptSaveFailed };
case ToastType.ReportedSpam:
return { toastType: ToastType.ReportedSpam };
case ToastType.ReportedSpamAndBlocked:

View file

@ -491,6 +491,28 @@ export function renderToast({
return <Toast onClose={hideToast}>{i18n('icu:Reactions--error')}</Toast>;
}
if (toastType === ToastType.ReceiptSaved) {
return (
<Toast
onClose={hideToast}
toastAction={{
label: i18n('icu:attachmentSavedShow'),
onClick: () => {
openFileInFolder(toast.parameters.fullPath);
},
}}
>
{i18n('icu:Toast--ReceiptSaved')}
</Toast>
);
}
if (toastType === ToastType.ReceiptSaveFailed) {
return (
<Toast onClose={hideToast}>{i18n('icu:Toast--ReceiptSaveFailed')}</Toast>
);
}
if (toastType === ToastType.ReportedSpam) {
return (
<Toast onClose={hideToast}>

View file

@ -7,11 +7,14 @@ import { useSelector } from 'react-redux';
import type { MutableRefObject } from 'react';
import { getIntl } from '../selectors/user';
import { getMe } from '../selectors/conversations';
import { PreferencesDonations } from '../../components/PreferencesDonations';
import type { Page } from '../../components/Preferences';
import { useDonationsActions } from '../ducks/donations';
import type { StateType } from '../reducer';
import { isStagingServer } from '../../util/isStagingServer';
import { generateDonationReceiptBlob } from '../../util/generateDonationReceipt';
import { useToastActions } from '../ducks/toast';
export const SmartPreferencesDonations = memo(
function SmartPreferencesDonations({
@ -25,15 +28,36 @@ export const SmartPreferencesDonations = memo(
}) {
const isStaging = isStagingServer();
const i18n = useSelector(getIntl);
const workflow = useSelector(
(state: StateType) => state.donations.currentWorkflow
);
const { clearWorkflow, submitDonation } = useDonationsActions();
const {
avatars: userAvatarData = [],
color,
firstName,
profileAvatarUrl,
} = useSelector(getMe);
const { showToast } = useToastActions();
const donationReceipts = useSelector(
(state: StateType) => state.donations.receipts
);
const { saveAttachmentToDisk } = window.Signal.Migrations;
return (
<PreferencesDonations
contentsRef={contentsRef}
i18n={i18n}
userAvatarData={userAvatarData}
color={color}
firstName={firstName}
profileAvatarUrl={profileAvatarUrl}
donationReceipts={donationReceipts}
saveAttachmentToDisk={saveAttachmentToDisk}
generateDonationReceiptBlob={generateDonationReceiptBlob}
showToast={showToast}
contentsRef={contentsRef}
isStaging={isStaging}
page={page}
workflow={workflow}

View file

@ -53,6 +53,8 @@ export enum ToastType {
OriginalMessageNotFound = 'OriginalMessageNotFound',
PinnedConversationsFull = 'PinnedConversationsFull',
ReactionFailed = 'ReactionFailed',
ReceiptSaved = 'ReceiptSaved',
ReceiptSaveFailed = 'ReceiptSaveFailed',
ReportedSpam = 'ReportedSpam',
ReportedSpamAndBlocked = 'ReportedSpamAndBlocked',
SQLError = 'SQLError',
@ -153,6 +155,11 @@ export type AnyToast =
| { toastType: ToastType.OriginalMessageNotFound }
| { toastType: ToastType.PinnedConversationsFull }
| { toastType: ToastType.ReactionFailed }
| {
toastType: ToastType.ReceiptSaved;
parameters: { fullPath: string };
}
| { toastType: ToastType.ReceiptSaveFailed }
| { toastType: ToastType.ReportedSpam }
| { toastType: ToastType.ReportedSpamAndBlocked }
| { toastType: ToastType.StickerPackInstallFailed }