ui: more comprehensive Payment Success view
This commit is contained in:
+21
-3
@@ -33,6 +33,7 @@ interface AmountDisplayProps {
|
||||
space?: boolean;
|
||||
jumboText?: boolean;
|
||||
defaultTextSize?: boolean;
|
||||
fontSize?: number;
|
||||
color?:
|
||||
| 'text'
|
||||
| 'success'
|
||||
@@ -63,6 +64,7 @@ function AmountDisplay({
|
||||
space = false,
|
||||
jumboText = false,
|
||||
defaultTextSize = false,
|
||||
fontSize = undefined,
|
||||
color = undefined,
|
||||
colorOverride = undefined,
|
||||
pending = false,
|
||||
@@ -98,6 +100,7 @@ function AmountDisplay({
|
||||
<Body
|
||||
secondary
|
||||
jumbo={jumboText}
|
||||
fontSize={fontSize}
|
||||
color={color}
|
||||
colorOverride={colorOverride}
|
||||
accessible={accessible}
|
||||
@@ -111,6 +114,7 @@ function AmountDisplay({
|
||||
<Body
|
||||
secondary
|
||||
jumbo={jumboText}
|
||||
fontSize={fontSize}
|
||||
color={color}
|
||||
colorOverride={colorOverride}
|
||||
accessible={accessible}
|
||||
@@ -124,6 +128,7 @@ function AmountDisplay({
|
||||
<Body
|
||||
jumbo={jumboText}
|
||||
defaultSize={defaultTextSize}
|
||||
fontSize={fontSize}
|
||||
color={color}
|
||||
colorOverride={colorOverride}
|
||||
accessible={accessible}
|
||||
@@ -133,7 +138,12 @@ function AmountDisplay({
|
||||
);
|
||||
|
||||
const TextSpace = () => (
|
||||
<Body jumbo={jumboText} color={color} colorOverride={colorOverride}>
|
||||
<Body
|
||||
jumbo={jumboText}
|
||||
fontSize={fontSize}
|
||||
color={color}
|
||||
colorOverride={colorOverride}
|
||||
>
|
||||
{' '}
|
||||
</Body>
|
||||
);
|
||||
@@ -156,6 +166,7 @@ function AmountDisplay({
|
||||
<Body
|
||||
jumbo={jumboText}
|
||||
defaultSize={defaultTextSize}
|
||||
fontSize={fontSize}
|
||||
color={color}
|
||||
colorOverride={colorOverride}
|
||||
accessible={accessible}
|
||||
@@ -167,8 +178,9 @@ function AmountDisplay({
|
||||
<View accessible={accessible}>
|
||||
<Body
|
||||
secondary
|
||||
small={!jumboText}
|
||||
small={!jumboText && !fontSize}
|
||||
defaultSize={defaultTextSize}
|
||||
fontSize={fontSize}
|
||||
color={color}
|
||||
colorOverride={colorOverride}
|
||||
accessible={accessible}
|
||||
@@ -194,8 +206,9 @@ function AmountDisplay({
|
||||
<View>
|
||||
<Body
|
||||
secondary
|
||||
small={!jumboText}
|
||||
small={!jumboText && !fontSize}
|
||||
defaultSize={defaultTextSize}
|
||||
fontSize={fontSize}
|
||||
color={color}
|
||||
colorOverride={colorOverride}
|
||||
>
|
||||
@@ -209,6 +222,7 @@ function AmountDisplay({
|
||||
<Body
|
||||
jumbo={jumboText}
|
||||
defaultSize={defaultTextSize}
|
||||
fontSize={fontSize}
|
||||
color={color}
|
||||
colorOverride={colorOverride}
|
||||
accessible={accessible}
|
||||
@@ -306,6 +320,7 @@ interface AmountProps {
|
||||
sensitiveLength?: number;
|
||||
jumboText?: boolean;
|
||||
defaultTextSize?: boolean;
|
||||
fontSize?: number;
|
||||
credit?: boolean;
|
||||
debit?: boolean;
|
||||
// If credit or debit doesn't cover the use case
|
||||
@@ -337,6 +352,7 @@ export default class Amount extends React.Component<AmountProps, {}> {
|
||||
sensitiveLength = 4,
|
||||
jumboText = false,
|
||||
defaultTextSize = false,
|
||||
fontSize = undefined,
|
||||
credit = false,
|
||||
debit = false,
|
||||
toggleable = false,
|
||||
@@ -395,6 +411,7 @@ export default class Amount extends React.Component<AmountProps, {}> {
|
||||
negative={negative}
|
||||
jumboText={jumboText}
|
||||
defaultTextSize={defaultTextSize}
|
||||
fontSize={fontSize}
|
||||
pending={pending}
|
||||
fee={fee}
|
||||
fiatRatesLoading={FiatStore.loading}
|
||||
@@ -466,6 +483,7 @@ export default class Amount extends React.Component<AmountProps, {}> {
|
||||
negative={negative}
|
||||
jumboText={jumboText}
|
||||
defaultTextSize={defaultTextSize}
|
||||
fontSize={fontSize}
|
||||
color={textColor}
|
||||
colorOverride={colorOverride}
|
||||
pending={pending}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Animated,
|
||||
Image,
|
||||
Modal,
|
||||
PanResponder,
|
||||
PanResponderInstance,
|
||||
ScrollView,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
import Amount from './Amount';
|
||||
import KeyValue from './KeyValue';
|
||||
import { Row } from './layout/Row';
|
||||
|
||||
import { getFeePercentage } from '../utils/AmountUtils';
|
||||
|
||||
import Contact from '../models/Contact';
|
||||
|
||||
import { localeString } from '../utils/LocaleUtils';
|
||||
import { themeColor } from '../utils/ThemeUtils';
|
||||
import { getPhoto } from '../utils/PhotoUtils';
|
||||
|
||||
import CaretRight from '../assets/images/SVG/Caret Right.svg';
|
||||
import CaretUp from '../assets/images/SVG/Caret Up.svg';
|
||||
import CloseSvg from '../assets/images/SVG/Close.svg';
|
||||
|
||||
interface PaymentDetailsSheetProps {
|
||||
isOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
paymentAmount?: string | number;
|
||||
feeAmount?: string | number;
|
||||
paymentDuration?: number | null;
|
||||
contact?: Contact | null;
|
||||
lightningAddress?: string;
|
||||
paymentHash?: string;
|
||||
paymentPreimage?: string;
|
||||
enhancedPath?: any;
|
||||
navigation: any;
|
||||
}
|
||||
|
||||
export default class PaymentDetailsSheet extends React.Component<PaymentDetailsSheetProps> {
|
||||
private pan: Animated.ValueXY;
|
||||
private panResponder: PanResponderInstance;
|
||||
|
||||
constructor(props: PaymentDetailsSheetProps) {
|
||||
super(props);
|
||||
this.pan = new Animated.ValueXY();
|
||||
this.panResponder = PanResponder.create({
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderMove: Animated.event(
|
||||
[null, { dx: this.pan.x, dy: this.pan.y }],
|
||||
{ useNativeDriver: false }
|
||||
),
|
||||
onPanResponderRelease: () => {
|
||||
Animated.spring(this.pan, {
|
||||
toValue: { x: 0, y: 0 },
|
||||
useNativeDriver: false
|
||||
}).start();
|
||||
props.onOpen();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderContactRow = () => {
|
||||
const { contact, lightningAddress } = this.props;
|
||||
|
||||
if (!contact && !lightningAddress) return null;
|
||||
|
||||
const contactPhoto = contact?.photo ? getPhoto(contact.photo) : null;
|
||||
const displayName = contact?.name || lightningAddress;
|
||||
|
||||
return (
|
||||
<KeyValue
|
||||
keyValue={localeString('views.Payment.recipient')}
|
||||
value={
|
||||
<Row>
|
||||
{contactPhoto ? (
|
||||
<Image
|
||||
source={{ uri: contactPhoto }}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
marginRight: 8
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'PPNeueMontreal-Book',
|
||||
color: themeColor('text')
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
</Row>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderPathRow = () => {
|
||||
const { enhancedPath, navigation, onClose } = this.props;
|
||||
|
||||
const pathExists = enhancedPath?.length > 0 && enhancedPath[0][0];
|
||||
if (!pathExists) return null;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
onClose();
|
||||
navigation.navigate('PaymentPaths', {
|
||||
enhancedPath
|
||||
});
|
||||
}}
|
||||
>
|
||||
<View style={{ marginTop: 10, marginBottom: 10 }}>
|
||||
<Row justify="space-between">
|
||||
<View style={{ flex: 1 }}>
|
||||
<KeyValue
|
||||
keyValue={
|
||||
enhancedPath.length > 1
|
||||
? `${localeString(
|
||||
'views.Payment.paths'
|
||||
)} (${enhancedPath.length})`
|
||||
: localeString('views.Payment.path')
|
||||
}
|
||||
disableCopy
|
||||
/>
|
||||
</View>
|
||||
<CaretRight
|
||||
fill={themeColor('text')}
|
||||
width="20"
|
||||
height="20"
|
||||
/>
|
||||
</Row>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
renderTrigger = () => {
|
||||
return (
|
||||
<Animated.View
|
||||
style={{
|
||||
width: '100%',
|
||||
transform: [{ translateY: this.pan.y }],
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 5
|
||||
}}
|
||||
{...this.panResponder.panHandlers}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={this.props.onOpen}
|
||||
accessibilityLabel={localeString('views.Payment.details')}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
padding: 10
|
||||
}}
|
||||
>
|
||||
<CaretUp fill={themeColor('text')} />
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 7,
|
||||
textAlign: 'center',
|
||||
fontFamily: 'PPNeueMontreal-Book',
|
||||
color: themeColor('text')
|
||||
}}
|
||||
>
|
||||
{localeString('views.Payment.details')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
isOpen,
|
||||
onClose,
|
||||
paymentAmount,
|
||||
feeAmount,
|
||||
paymentDuration,
|
||||
paymentHash,
|
||||
paymentPreimage
|
||||
} = this.props;
|
||||
|
||||
const feePercentage = getFeePercentage(feeAmount, paymentAmount);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
visible={isOpen}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<TouchableWithoutFeedback>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor:
|
||||
themeColor('secondary'),
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
width: '90%',
|
||||
maxHeight: '80%'
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily:
|
||||
'PPNeueMontreal-Medium',
|
||||
color: themeColor('text'),
|
||||
fontSize: 20,
|
||||
textAlign: 'center',
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
{localeString(
|
||||
'views.Payment.details'
|
||||
)}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
hitSlop={{
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
right: 8
|
||||
}}
|
||||
style={{ padding: 4 }}
|
||||
>
|
||||
<CloseSvg
|
||||
fill={themeColor('text')}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{paymentAmount != null && (
|
||||
<KeyValue
|
||||
keyValue={localeString(
|
||||
'views.Receive.amount'
|
||||
)}
|
||||
value={
|
||||
<Amount
|
||||
sats={paymentAmount}
|
||||
debit
|
||||
sensitive
|
||||
toggleable
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{feeAmount != null && (
|
||||
<KeyValue
|
||||
keyValue={localeString(
|
||||
'views.Payment.fee'
|
||||
)}
|
||||
value={
|
||||
<Row>
|
||||
<Amount
|
||||
sats={feeAmount}
|
||||
debit
|
||||
sensitive
|
||||
toggleable
|
||||
/>
|
||||
{feePercentage ? (
|
||||
<Text
|
||||
style={{
|
||||
fontFamily:
|
||||
'PPNeueMontreal-Book',
|
||||
color: themeColor(
|
||||
'text'
|
||||
)
|
||||
}}
|
||||
>
|
||||
{` (${feePercentage})`}
|
||||
</Text>
|
||||
) : null}
|
||||
</Row>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{paymentDuration != null && (
|
||||
<KeyValue
|
||||
keyValue={localeString(
|
||||
'views.Payment.settlementTime'
|
||||
)}
|
||||
value={`${paymentDuration.toFixed(
|
||||
2
|
||||
)}s`}
|
||||
disableCopy
|
||||
/>
|
||||
)}
|
||||
|
||||
{this.renderContactRow()}
|
||||
|
||||
{paymentHash ? (
|
||||
<KeyValue
|
||||
keyValue={localeString(
|
||||
'views.Payment.paymentHash'
|
||||
)}
|
||||
value={paymentHash}
|
||||
sensitive
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{paymentPreimage ? (
|
||||
<KeyValue
|
||||
keyValue={localeString(
|
||||
'views.Payment.paymentPreimage'
|
||||
)}
|
||||
value={paymentPreimage}
|
||||
sensitive
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{this.renderPathRow()}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
{this.renderTrigger()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export function Body({
|
||||
big = false,
|
||||
jumbo = false,
|
||||
defaultSize = false,
|
||||
fontSize: fontSizeOverride = undefined,
|
||||
color = undefined,
|
||||
colorOverride = undefined,
|
||||
children,
|
||||
@@ -22,6 +23,7 @@ export function Body({
|
||||
big?: boolean;
|
||||
jumbo?: boolean;
|
||||
defaultSize?: boolean;
|
||||
fontSize?: number;
|
||||
// These should only be keys available on the theme
|
||||
// TODO: enforce this with some global ThemeKey enum?
|
||||
colorOverride?: string;
|
||||
@@ -50,7 +52,9 @@ export function Body({
|
||||
: secondary
|
||||
? themeColor('secondaryText')
|
||||
: themeColor('text'),
|
||||
fontSize: small
|
||||
fontSize: fontSizeOverride
|
||||
? fontSizeOverride
|
||||
: small
|
||||
? 12
|
||||
: big
|
||||
? 20
|
||||
|
||||
@@ -689,6 +689,9 @@
|
||||
"views.Payment.creationDate": "Creation Date",
|
||||
"views.Payment.path": "Path",
|
||||
"views.Payment.paths": "Paths",
|
||||
"views.Payment.details": "Payment Details",
|
||||
"views.Payment.recipient": "Recipient",
|
||||
"views.Payment.settlementTime": "Settlement time",
|
||||
"views.Payment.writeNote": "Write your note here",
|
||||
"views.PaymentRequest.title": "Lightning Invoice",
|
||||
"views.PaymentRequest.error": "Error loading invoice",
|
||||
|
||||
+2
-10
@@ -5,6 +5,7 @@ import humanizeDuration from 'humanize-duration';
|
||||
|
||||
import BaseModel from './BaseModel';
|
||||
import DateTimeUtils from '../utils/DateTimeUtils';
|
||||
import { getFeePercentage } from '../utils/AmountUtils';
|
||||
import { localeString } from '../utils/LocaleUtils';
|
||||
import Bolt11Utils from '../utils/Bolt11Utils';
|
||||
import Base64Utils from '../utils/Base64Utils';
|
||||
@@ -305,16 +306,7 @@ export default class Payment extends BaseModel {
|
||||
}
|
||||
|
||||
@computed public get getFeePercentage(): string {
|
||||
const amount = this.getAmount;
|
||||
const fee = this.getFee;
|
||||
if (!fee || !amount || fee == '0') return '';
|
||||
|
||||
// use at most 3 decimal places and remove trailing 0s
|
||||
return (
|
||||
Number(new BigNumber(fee).div(amount).times(100).toFixed(3))
|
||||
.toString()
|
||||
.replace(/-/g, '') + '%'
|
||||
);
|
||||
return getFeePercentage(this.getFee, this.getAmount);
|
||||
}
|
||||
|
||||
@computed public get enhancedPath(): any[] {
|
||||
|
||||
+2
-10
@@ -2,6 +2,7 @@ import { computed } from 'mobx';
|
||||
import BigNumber from 'bignumber.js';
|
||||
|
||||
import BaseModel from './BaseModel';
|
||||
import { getFeePercentage } from '../utils/AmountUtils';
|
||||
import DateTimeUtils from '../utils/DateTimeUtils';
|
||||
import { localeString } from '../utils/LocaleUtils';
|
||||
import { notesStore } from '../stores/Stores';
|
||||
@@ -55,16 +56,7 @@ export default class Transaction extends BaseModel {
|
||||
}
|
||||
|
||||
@computed public get getFeePercentage(): string {
|
||||
const amount = this.getAmount;
|
||||
const fee = this.getFee;
|
||||
if (!fee || !amount || fee == '0' || amount == '0') return '';
|
||||
|
||||
// use at most 3 decimal places and remove trailing 0s
|
||||
return (
|
||||
Number(new BigNumber(fee).div(amount).times(100).toFixed(3))
|
||||
.toString()
|
||||
.replace(/-/g, '') + '%'
|
||||
);
|
||||
return getFeePercentage(this.getFee, this.getAmount);
|
||||
}
|
||||
|
||||
@computed public get getTimestamp(): string | number {
|
||||
|
||||
@@ -184,6 +184,7 @@ export default class CashuStore {
|
||||
@observable public noteKey?: string;
|
||||
@observable public paymentStartTime?: number;
|
||||
@observable public paymentDuration?: number;
|
||||
@observable public paymentFee?: number;
|
||||
|
||||
@observable public mintRecommendations?: MintRecommendation[];
|
||||
@observable public trustedMintRecommendations?: MintRecommendation[];
|
||||
@@ -266,6 +267,7 @@ export default class CashuStore {
|
||||
this.paymentErrorMsg = undefined;
|
||||
this.paymentStartTime = undefined;
|
||||
this.paymentDuration = undefined;
|
||||
this.paymentFee = undefined;
|
||||
};
|
||||
|
||||
getLndDir = () => {
|
||||
@@ -2159,6 +2161,7 @@ export default class CashuStore {
|
||||
|
||||
if (!isDonationPayment) {
|
||||
this.paymentPreimage = paymentPreimage;
|
||||
this.paymentFee = realFee;
|
||||
}
|
||||
|
||||
if (this.paymentStartTime) {
|
||||
|
||||
@@ -52,6 +52,7 @@ export default class LnurlPayStore {
|
||||
zaplockerNpub: string | undefined;
|
||||
relays: Array<string> | undefined;
|
||||
paymentRequest: string | undefined;
|
||||
lightningAddress: string | undefined;
|
||||
|
||||
constructor(settingsStore: SettingsStore, nodeInfoStore: NodeInfoStore) {
|
||||
this.settingsStore = settingsStore;
|
||||
@@ -69,6 +70,7 @@ export default class LnurlPayStore {
|
||||
this.zaplockerNpub = undefined;
|
||||
this.relays = undefined;
|
||||
this.paymentRequest = undefined;
|
||||
this.lightningAddress = undefined;
|
||||
};
|
||||
|
||||
public load = async (paymentHash: string): Promise<LnurlPayTransaction> => {
|
||||
@@ -97,7 +99,8 @@ export default class LnurlPayStore {
|
||||
user_pubkey?: string,
|
||||
relays?: Array<string>,
|
||||
relays_sig?: string,
|
||||
pr?: string
|
||||
pr?: string,
|
||||
lightningAddress?: string
|
||||
) => {
|
||||
this.reset();
|
||||
const now = new Date().getTime();
|
||||
@@ -125,6 +128,7 @@ export default class LnurlPayStore {
|
||||
this.domain = domain;
|
||||
|
||||
if (pr) this.paymentRequest = pr;
|
||||
if (lightningAddress) this.lightningAddress = lightningAddress;
|
||||
|
||||
// Zaplocker
|
||||
if (user_pubkey) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getUnformattedAmount,
|
||||
getAmountFromSats,
|
||||
getFormattedAmount,
|
||||
getFeePercentage,
|
||||
getSatAmount
|
||||
} from './AmountUtils';
|
||||
import { settingsStore, fiatStore, unitsStore } from '../stores/Stores';
|
||||
@@ -995,4 +996,56 @@ describe('AmountUtils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFeePercentage', () => {
|
||||
it('calculates a simple percentage', () => {
|
||||
expect(getFeePercentage(10, 1000)).toBe('1%');
|
||||
});
|
||||
|
||||
it('calculates a fractional percentage', () => {
|
||||
expect(getFeePercentage(15, 1000)).toBe('1.5%');
|
||||
});
|
||||
|
||||
it('caps at 3 decimal places and removes trailing zeros', () => {
|
||||
expect(getFeePercentage(1, 3000)).toBe('0.033%');
|
||||
});
|
||||
|
||||
it('handles string inputs', () => {
|
||||
expect(getFeePercentage('50', '1000')).toBe('5%');
|
||||
});
|
||||
|
||||
it('returns empty string when fee is 0', () => {
|
||||
expect(getFeePercentage(0, 1000)).toBe('');
|
||||
expect(getFeePercentage('0', 1000)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when amount is 0', () => {
|
||||
expect(getFeePercentage(10, 0)).toBe('');
|
||||
expect(getFeePercentage(10, '0')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when fee is undefined', () => {
|
||||
expect(getFeePercentage(undefined, 1000)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when amount is undefined', () => {
|
||||
expect(getFeePercentage(10, undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('handles negative fee by stripping the minus sign', () => {
|
||||
expect(getFeePercentage(-10, 1000)).toBe('1%');
|
||||
});
|
||||
|
||||
it('handles 100% fee', () => {
|
||||
expect(getFeePercentage(1000, 1000)).toBe('100%');
|
||||
});
|
||||
|
||||
it('handles fee larger than amount', () => {
|
||||
expect(getFeePercentage(1500, 1000)).toBe('150%');
|
||||
});
|
||||
|
||||
it('handles very small percentages', () => {
|
||||
expect(getFeePercentage(1, 1000000)).toBe('0%');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -311,6 +311,27 @@ export function getFormattedAmount(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the fee as a percentage of the payment amount.
|
||||
* Returns a string like "1.5%" or empty string if not applicable.
|
||||
*
|
||||
* @param fee - The fee amount (string or number)
|
||||
* @param amount - The payment amount (string or number)
|
||||
* @returns Formatted percentage string (e.g., "1.5%") or empty string
|
||||
*/
|
||||
export function getFeePercentage(
|
||||
fee: string | number | undefined,
|
||||
amount: string | number | undefined
|
||||
): string {
|
||||
if (!fee || !amount || fee == '0' || amount == '0') return '';
|
||||
|
||||
return (
|
||||
Number(new BigNumber(fee).div(amount).times(100).toFixed(3))
|
||||
.toString()
|
||||
.replace(/-/g, '') + '%'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts satoshis to millisatoshis
|
||||
* @param sats - Amount in satoshis
|
||||
|
||||
+17
-1
@@ -2,6 +2,8 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import RNFS from 'react-native-fs';
|
||||
|
||||
import Contact from '../models/Contact';
|
||||
|
||||
const transformContactData = async (contact: any) => {
|
||||
try {
|
||||
const name = contact?.display_name || contact?.name || '';
|
||||
@@ -78,4 +80,18 @@ const transformContactData = async (contact: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
export default { transformContactData };
|
||||
const findContactByLightningAddress = (
|
||||
lightningAddress: string | undefined,
|
||||
contacts: Contact[] | undefined
|
||||
): Contact | null => {
|
||||
if (!lightningAddress || !contacts?.length) return null;
|
||||
const normalized = lightningAddress.toLowerCase();
|
||||
const found = contacts.find((c: any) =>
|
||||
c.lnAddress?.some(
|
||||
(addr: string) => addr && addr.toLowerCase() === normalized
|
||||
)
|
||||
);
|
||||
return found ? new Contact(found) : null;
|
||||
};
|
||||
|
||||
export default { transformContactData, findContactByLightningAddress };
|
||||
|
||||
@@ -23,10 +23,12 @@ import SuccessAnimation from '../../components/SuccessAnimation';
|
||||
import { Row } from '../../components/layout/Row';
|
||||
|
||||
import CashuStore from '../../stores/CashuStore';
|
||||
import ContactStore from '../../stores/ContactStore';
|
||||
import LnurlPayStore from '../../stores/LnurlPayStore';
|
||||
import SettingsStore from '../../stores/SettingsStore';
|
||||
import NodeInfoStore from '../../stores/NodeInfoStore';
|
||||
|
||||
import ContactUtils from '../../utils/ContactUtils';
|
||||
import { localeString } from '../../utils/LocaleUtils';
|
||||
import { themeColor } from '../../utils/ThemeUtils';
|
||||
import UrlUtils from '../../utils/UrlUtils';
|
||||
@@ -44,10 +46,12 @@ import Amount from '../../components/Amount';
|
||||
import ModalBox from '../../components/ModalBox';
|
||||
import LoadingIndicator from '../../components/LoadingIndicator';
|
||||
import Header from '../../components/Header';
|
||||
import PaymentDetailsSheet from '../../components/PaymentDetailsSheet';
|
||||
|
||||
interface CashuSendingLightningProps {
|
||||
navigation: StackNavigationProp<any, any>;
|
||||
CashuStore: CashuStore;
|
||||
ContactStore: ContactStore;
|
||||
LnurlPayStore: LnurlPayStore;
|
||||
SettingsStore: SettingsStore;
|
||||
NodeInfoStore: NodeInfoStore;
|
||||
@@ -71,9 +75,10 @@ interface CashuSendingLightningState {
|
||||
amountDonated: number | null;
|
||||
donationIsPaid: boolean;
|
||||
showZaplockerWarning: boolean;
|
||||
showPaymentDetails: boolean;
|
||||
}
|
||||
|
||||
@inject('CashuStore', 'LnurlPayStore', 'NodeInfoStore')
|
||||
@inject('CashuStore', 'ContactStore', 'LnurlPayStore', 'NodeInfoStore')
|
||||
@observer
|
||||
export default class CashuSendingLightning extends React.Component<
|
||||
CashuSendingLightningProps,
|
||||
@@ -93,7 +98,8 @@ export default class CashuSendingLightning extends React.Component<
|
||||
wasSuccessful: false,
|
||||
paymentType: 'main',
|
||||
donationIsPaid: false,
|
||||
showZaplockerWarning: false
|
||||
showZaplockerWarning: false,
|
||||
showPaymentDetails: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -422,7 +428,8 @@ export default class CashuSendingLightning extends React.Component<
|
||||
}
|
||||
|
||||
render() {
|
||||
const { CashuStore, LnurlPayStore, navigation } = this.props;
|
||||
const { CashuStore, ContactStore, LnurlPayStore, navigation } =
|
||||
this.props;
|
||||
const {
|
||||
loading,
|
||||
paymentError,
|
||||
@@ -431,14 +438,28 @@ export default class CashuSendingLightning extends React.Component<
|
||||
paymentPreimage,
|
||||
paymentErrorMsg,
|
||||
noteKey,
|
||||
paymentDuration
|
||||
paymentDuration,
|
||||
paymentFee
|
||||
} = CashuStore;
|
||||
const payment_hash = payReq && payReq.payment_hash;
|
||||
const { storedNotes, donationHandled, paymentType, payingDonation } =
|
||||
this.state;
|
||||
const paymentAmount = payReq?.getRequestAmount;
|
||||
const {
|
||||
storedNotes,
|
||||
donationHandled,
|
||||
paymentType,
|
||||
payingDonation,
|
||||
showPaymentDetails
|
||||
} = this.state;
|
||||
|
||||
const lightningAddress = LnurlPayStore.lightningAddress;
|
||||
const matchedContact = ContactUtils.findContactByLightningAddress(
|
||||
lightningAddress,
|
||||
ContactStore?.contacts
|
||||
);
|
||||
|
||||
const success = this.successfullySent(CashuStore);
|
||||
const windowSize = Dimensions.get('window');
|
||||
const amountFontSize = windowSize.width * windowSize.scale * 0.013;
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
@@ -555,6 +576,66 @@ export default class CashuSendingLightning extends React.Component<
|
||||
'views.SendingLightning.success'
|
||||
)}
|
||||
</Text>
|
||||
{paymentAmount != null && (
|
||||
<Row
|
||||
style={{
|
||||
marginTop: 10,
|
||||
alignItems: 'baseline'
|
||||
}}
|
||||
>
|
||||
<Amount
|
||||
sats={paymentAmount}
|
||||
sensitive
|
||||
toggleable
|
||||
fontSize={amountFontSize}
|
||||
/>
|
||||
{paymentFee != null && (
|
||||
<Row
|
||||
style={{
|
||||
alignItems:
|
||||
'baseline'
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: themeColor(
|
||||
'secondaryText'
|
||||
),
|
||||
fontFamily:
|
||||
'PPNeueMontreal-Book',
|
||||
fontSize:
|
||||
amountFontSize
|
||||
}}
|
||||
>
|
||||
{' (+'}
|
||||
</Text>
|
||||
<Amount
|
||||
sats={paymentFee}
|
||||
sensitive
|
||||
toggleable
|
||||
fontSize={
|
||||
amountFontSize
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: themeColor(
|
||||
'secondaryText'
|
||||
),
|
||||
fontFamily:
|
||||
'PPNeueMontreal-Book',
|
||||
fontSize:
|
||||
amountFontSize
|
||||
}}
|
||||
>
|
||||
{` ${localeString(
|
||||
'views.Payment.fee'
|
||||
).toLowerCase()})`}
|
||||
</Text>
|
||||
</Row>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
{paymentDuration !== undefined && (
|
||||
<Text
|
||||
style={{
|
||||
@@ -638,30 +719,37 @@ export default class CashuSendingLightning extends React.Component<
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{!!paymentPreimage &&
|
||||
!paymentError &&
|
||||
!paymentErrorMsg && (
|
||||
<View style={{ width: '90%' }}>
|
||||
<CopyBox
|
||||
heading={localeString(
|
||||
'views.Payment.paymentPreimage'
|
||||
)}
|
||||
headingCopied={`${localeString(
|
||||
'views.Payment.paymentPreimage'
|
||||
)} ${localeString(
|
||||
'components.ExternalLinkModal.copied'
|
||||
)}`}
|
||||
theme="dark"
|
||||
URL={paymentPreimage}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!!success && !paymentError && !paymentErrorMsg && (
|
||||
<PaymentDetailsSheet
|
||||
isOpen={showPaymentDetails}
|
||||
onOpen={() =>
|
||||
this.setState({
|
||||
showPaymentDetails: true
|
||||
})
|
||||
}
|
||||
onClose={() =>
|
||||
this.setState({
|
||||
showPaymentDetails: false
|
||||
})
|
||||
}
|
||||
paymentAmount={paymentAmount}
|
||||
feeAmount={paymentFee}
|
||||
paymentDuration={paymentDuration}
|
||||
contact={matchedContact}
|
||||
lightningAddress={lightningAddress}
|
||||
paymentHash={payment_hash}
|
||||
paymentPreimage={paymentPreimage}
|
||||
navigation={navigation}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Row
|
||||
align="flex-end"
|
||||
style={{
|
||||
bottom: 25,
|
||||
marginTop: 10,
|
||||
marginBottom: 15,
|
||||
alignSelf: 'center'
|
||||
}}
|
||||
>
|
||||
@@ -684,8 +772,7 @@ export default class CashuSendingLightning extends React.Component<
|
||||
secondary
|
||||
buttonStyle={{ height: 40, width: '100%' }}
|
||||
containerStyle={{
|
||||
maxWidth: '100%',
|
||||
margin: 10
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -799,7 +886,6 @@ const styles = StyleSheet.create({
|
||||
buttons: {
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
gap: 15,
|
||||
bottom: 15
|
||||
gap: 15
|
||||
}
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@ export default class LnurlPay extends React.Component<
|
||||
|
||||
const { navigation, CashuStore, InvoicesStore, LnurlPayStore, route } =
|
||||
this.props;
|
||||
const { domain, comment } = this.state;
|
||||
const { domain, comment, lightningAddress } = this.state;
|
||||
const ecash = route.params?.ecash;
|
||||
const lnurl = route.params?.lnurlParams;
|
||||
const u = url.parse(lnurl.callback);
|
||||
@@ -291,7 +291,8 @@ export default class LnurlPay extends React.Component<
|
||||
user_pubkey,
|
||||
relays,
|
||||
relays_sig,
|
||||
pr
|
||||
pr,
|
||||
lightningAddress
|
||||
);
|
||||
|
||||
navigation.navigate('ChoosePaymentMethod', {
|
||||
@@ -341,7 +342,8 @@ export default class LnurlPay extends React.Component<
|
||||
user_pubkey,
|
||||
relays,
|
||||
relays_sig,
|
||||
pr
|
||||
pr,
|
||||
lightningAddress
|
||||
);
|
||||
navigation.navigate('PaymentRequest');
|
||||
});
|
||||
|
||||
+121
-68
@@ -27,6 +27,7 @@ import Amount from '../components/Amount';
|
||||
import ModalBox from '../components/ModalBox';
|
||||
|
||||
import BalanceStore from '../stores/BalanceStore';
|
||||
import ContactStore from '../stores/ContactStore';
|
||||
import LnurlPayStore from '../stores/LnurlPayStore';
|
||||
import PaymentsStore from '../stores/PaymentsStore';
|
||||
import SettingsStore from '../stores/SettingsStore';
|
||||
@@ -35,6 +36,7 @@ import NodeInfoStore from '../stores/NodeInfoStore';
|
||||
|
||||
import Base64Utils from '../utils/Base64Utils';
|
||||
import BackendUtils from '../utils/BackendUtils';
|
||||
import ContactUtils from '../utils/ContactUtils';
|
||||
import { localeString } from '../utils/LocaleUtils';
|
||||
import { themeColor } from '../utils/ThemeUtils';
|
||||
|
||||
@@ -46,11 +48,14 @@ import Wordmark from '../assets/images/SVG/wordmark-black.svg';
|
||||
import Gift from '../assets/images/SVG/gift.svg';
|
||||
import CopyBox from '../components/CopyBox';
|
||||
import LoadingIndicator from '../components/LoadingIndicator';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import PaymentDetailsSheet from '../components/PaymentDetailsSheet';
|
||||
|
||||
import { getFeePercentage } from '../utils/AmountUtils';
|
||||
|
||||
interface SendingLightningProps {
|
||||
navigation: StackNavigationProp<any, any>;
|
||||
BalanceStore: BalanceStore;
|
||||
ContactStore: ContactStore;
|
||||
LnurlPayStore: LnurlPayStore;
|
||||
PaymentsStore: PaymentsStore;
|
||||
SettingsStore: SettingsStore;
|
||||
@@ -79,10 +84,12 @@ interface SendingLightningState {
|
||||
donationPathExists: boolean;
|
||||
donationFee: string;
|
||||
donationFeePercentage: string;
|
||||
showPaymentDetails: boolean;
|
||||
}
|
||||
|
||||
@inject(
|
||||
'BalanceStore',
|
||||
'ContactStore',
|
||||
'LnurlPayStore',
|
||||
'PaymentsStore',
|
||||
'SettingsStore',
|
||||
@@ -113,7 +120,8 @@ export default class SendingLightning extends React.Component<
|
||||
donationEnhancedPath: null,
|
||||
donationPathExists: false,
|
||||
donationFee: '',
|
||||
donationFeePercentage: ''
|
||||
donationFeePercentage: '',
|
||||
showPaymentDetails: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -239,15 +247,10 @@ export default class SendingLightning extends React.Component<
|
||||
donationFee = (Number(payment.fee_msat) / 1000).toString();
|
||||
}
|
||||
|
||||
const donationFeePercentage =
|
||||
Number(
|
||||
new BigNumber(donationFee)
|
||||
.div(amountDonated)
|
||||
.times(100)
|
||||
.toFixed(3)
|
||||
)
|
||||
.toString()
|
||||
.replace(/-/g, '') + '%';
|
||||
const donationFeePercentage = getFeePercentage(
|
||||
donationFee,
|
||||
amountDonated
|
||||
);
|
||||
|
||||
let donationEnhancedPath = null;
|
||||
let donationPathExists = false;
|
||||
@@ -560,16 +563,21 @@ export default class SendingLightning extends React.Component<
|
||||
}
|
||||
|
||||
render() {
|
||||
const { TransactionsStore, SettingsStore, LnurlPayStore, navigation } =
|
||||
this.props;
|
||||
const {
|
||||
TransactionsStore,
|
||||
ContactStore,
|
||||
SettingsStore,
|
||||
LnurlPayStore,
|
||||
navigation
|
||||
} = this.props;
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
error_msg,
|
||||
payment_hash,
|
||||
payment_preimage,
|
||||
payment_fee,
|
||||
payment_error,
|
||||
isIncomplete,
|
||||
noteKey,
|
||||
paymentDuration
|
||||
} = TransactionsStore;
|
||||
@@ -579,17 +587,24 @@ export default class SendingLightning extends React.Component<
|
||||
currentPayment,
|
||||
donationHandled,
|
||||
paymentType,
|
||||
payingDonation
|
||||
payingDonation,
|
||||
showPaymentDetails
|
||||
} = this.state;
|
||||
|
||||
const enhancedPath = currentPayment?.enhancedPath;
|
||||
const paymentAmount = currentPayment?.getAmount;
|
||||
const feeAmount = payment_fee || currentPayment?.getFee;
|
||||
|
||||
const paymentPathExists =
|
||||
enhancedPath?.length > 0 && enhancedPath[0][0];
|
||||
const lightningAddress = LnurlPayStore.lightningAddress;
|
||||
const matchedContact = ContactUtils.findContactByLightningAddress(
|
||||
lightningAddress,
|
||||
ContactStore?.contacts
|
||||
);
|
||||
|
||||
const success = this.successfullySent(TransactionsStore);
|
||||
const inTransit = this.inTransit(TransactionsStore);
|
||||
const windowSize = Dimensions.get('window');
|
||||
const amountFontSize = windowSize.width * windowSize.scale * 0.013;
|
||||
|
||||
const stack = getRouteStack();
|
||||
const isSwap = stack.filter((route) => route.name === 'SwapDetails')[0];
|
||||
@@ -689,6 +704,66 @@ export default class SendingLightning extends React.Component<
|
||||
'views.SendingLightning.success'
|
||||
)}
|
||||
</Text>
|
||||
{paymentAmount != null && (
|
||||
<Row
|
||||
style={{
|
||||
marginTop: 10,
|
||||
alignItems: 'baseline'
|
||||
}}
|
||||
>
|
||||
<Amount
|
||||
sats={paymentAmount}
|
||||
sensitive
|
||||
toggleable
|
||||
fontSize={amountFontSize}
|
||||
/>
|
||||
{feeAmount != null && (
|
||||
<Row
|
||||
style={{
|
||||
alignItems:
|
||||
'baseline'
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: themeColor(
|
||||
'secondaryText'
|
||||
),
|
||||
fontFamily:
|
||||
'PPNeueMontreal-Book',
|
||||
fontSize:
|
||||
amountFontSize
|
||||
}}
|
||||
>
|
||||
{' (+'}
|
||||
</Text>
|
||||
<Amount
|
||||
sats={feeAmount}
|
||||
sensitive
|
||||
toggleable
|
||||
fontSize={
|
||||
amountFontSize
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: themeColor(
|
||||
'secondaryText'
|
||||
),
|
||||
fontFamily:
|
||||
'PPNeueMontreal-Book',
|
||||
fontSize:
|
||||
amountFontSize
|
||||
}}
|
||||
>
|
||||
{` ${localeString(
|
||||
'views.Payment.fee'
|
||||
).toLowerCase()})`}
|
||||
</Text>
|
||||
</Row>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
{paymentDuration !== null && (
|
||||
<Text
|
||||
style={{
|
||||
@@ -840,59 +915,41 @@ export default class SendingLightning extends React.Component<
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{!!payment_preimage &&
|
||||
!isIncomplete &&
|
||||
!error &&
|
||||
!payment_error && (
|
||||
<View style={{ width: '90%' }}>
|
||||
<CopyBox
|
||||
heading={localeString(
|
||||
'views.Payment.paymentPreimage'
|
||||
)}
|
||||
headingCopied={`${localeString(
|
||||
'views.Payment.paymentPreimage'
|
||||
)} ${localeString(
|
||||
'components.ExternalLinkModal.copied'
|
||||
)}`}
|
||||
theme="dark"
|
||||
URL={payment_preimage}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!!success && !error && !payment_error && (
|
||||
<PaymentDetailsSheet
|
||||
isOpen={showPaymentDetails}
|
||||
onOpen={() =>
|
||||
this.setState({
|
||||
showPaymentDetails: true
|
||||
})
|
||||
}
|
||||
onClose={() =>
|
||||
this.setState({
|
||||
showPaymentDetails: false
|
||||
})
|
||||
}
|
||||
paymentAmount={paymentAmount}
|
||||
feeAmount={feeAmount}
|
||||
paymentDuration={paymentDuration}
|
||||
contact={matchedContact}
|
||||
lightningAddress={lightningAddress}
|
||||
paymentHash={payment_hash}
|
||||
paymentPreimage={payment_preimage || undefined}
|
||||
enhancedPath={enhancedPath}
|
||||
navigation={navigation}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Row
|
||||
align="flex-end"
|
||||
style={{
|
||||
marginBottom: 5,
|
||||
bottom: 25,
|
||||
marginTop: 10,
|
||||
marginBottom: 15,
|
||||
alignSelf: 'center'
|
||||
}}
|
||||
>
|
||||
{paymentPathExists && (
|
||||
<Button
|
||||
title={`${localeString(
|
||||
'views.Payment.title'
|
||||
)} ${
|
||||
enhancedPath?.length > 1
|
||||
? `${localeString(
|
||||
'views.Payment.paths'
|
||||
)} (${enhancedPath.length})`
|
||||
: localeString('views.Payment.path')
|
||||
} `}
|
||||
onPress={() =>
|
||||
navigation.navigate('PaymentPaths', {
|
||||
enhancedPath
|
||||
})
|
||||
}
|
||||
secondary
|
||||
buttonStyle={{ height: 40, width: '100%' }}
|
||||
containerStyle={{
|
||||
maxWidth: '45%',
|
||||
paddingRight: 5
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{noteKey && !error && !payment_error && (
|
||||
<Button
|
||||
title={
|
||||
@@ -912,10 +969,7 @@ export default class SendingLightning extends React.Component<
|
||||
secondary
|
||||
buttonStyle={{ height: 40, width: '100%' }}
|
||||
containerStyle={{
|
||||
maxWidth: paymentPathExists
|
||||
? '45%'
|
||||
: '100%',
|
||||
paddingLeft: paymentPathExists ? 5 : 0
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1081,7 +1135,6 @@ const styles = StyleSheet.create({
|
||||
buttons: {
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
gap: 15,
|
||||
bottom: 15
|
||||
gap: 15
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user