Merge pull request #3657 from kaloudis/ui-lightning-address-meta

ui: add Lightning address metadata to LNURL-pay flows
This commit is contained in:
Evan Kaloudis
2026-02-12 09:21:53 -05:00
committed by GitHub
5 changed files with 179 additions and 66 deletions
@@ -286,7 +286,8 @@ export default class LightningSwipeableRow extends Component<
lnurlParams: response,
ecash:
BackendUtils.supportsCashuWallet() &&
settings?.ecash?.enableCashu
settings?.ecash?.enableCashu,
lightningAddress
});
})
.catch((error: any) => {
@@ -305,7 +306,8 @@ export default class LightningSwipeableRow extends Component<
lnurlParams: data,
ecash:
BackendUtils.supportsCashuWallet() &&
settings?.ecash?.enableCashu
settings?.ecash?.enableCashu,
lightningAddress
});
} else {
throw new Error(error);
+3 -1
View File
@@ -1112,7 +1112,9 @@ describe('handleAnything', () => {
{
lnurlParams: { callback: 'https://cryptoqr.net/callback' },
satAmount: undefined,
ecash: false
ecash: false,
lightningAddress:
'https%3A%2F%2Fzapper.com%2Fpayment%2F12345@cryptoqr.net'
}
]);
});
+4 -2
View File
@@ -594,7 +594,8 @@ const handleAnything = async (
{
lnurlParams: response,
satAmount: setAmount,
ecash
ecash,
lightningAddress: value
}
];
})
@@ -655,7 +656,8 @@ const handleAnything = async (
{
lnurlParams: lnurlpData,
satAmount: setAmount,
ecash
ecash,
lightningAddress: value
}
];
} else {
+130 -23
View File
@@ -1,7 +1,7 @@
import url from 'url';
import * as React from 'react';
import ReactNativeBlobUtil from 'react-native-blob-util';
import { Alert, StyleSheet, Text, View } from 'react-native';
import { Alert, Image, StyleSheet, Text, View } from 'react-native';
import { inject, observer } from 'mobx-react';
import querystring from 'querystring-es3';
import { Route } from '@react-navigation/native';
@@ -17,10 +17,13 @@ import { Row } from '../..//components/layout/Row';
import LoadingIndicator from '../../components/LoadingIndicator';
import CashuStore from '../../stores/CashuStore';
import ContactStore from '../../stores/ContactStore';
import InvoicesStore from '../../stores/InvoicesStore';
import LnurlPayStore from '../../stores/LnurlPayStore';
import UnitsStore from '../../stores/UnitsStore';
import Contact from '../../models/Contact';
import LnurlPayMetadata from './Metadata';
import { localeString } from '../../utils/LocaleUtils';
@@ -31,12 +34,19 @@ import { ScrollView } from 'react-native-gesture-handler';
interface LnurlPayProps {
navigation: StackNavigationProp<any, any>;
CashuStore: CashuStore;
ContactStore: ContactStore;
InvoicesStore: InvoicesStore;
LnurlPayStore: LnurlPayStore;
UnitsStore: UnitsStore;
route: Route<
'LnurlPay',
{ lnurlParams: any; amount: any; satAmount: any; ecash: boolean }
{
lnurlParams: any;
amount: any;
satAmount: any;
ecash: boolean;
lightningAddress?: string;
}
>;
}
@@ -44,11 +54,19 @@ interface LnurlPayState {
amount: string;
satAmount: string | number;
domain: string;
lightningAddress: string;
matchedContact: Contact | null;
comment: string;
loading: boolean;
}
@inject('CashuStore', 'InvoicesStore', 'LnurlPayStore', 'UnitsStore')
@inject(
'CashuStore',
'ContactStore',
'InvoicesStore',
'LnurlPayStore',
'UnitsStore'
)
@observer
export default class LnurlPay extends React.Component<
LnurlPayProps,
@@ -67,6 +85,8 @@ export default class LnurlPay extends React.Component<
amount: '',
satAmount: '',
domain: '',
lightningAddress: '',
matchedContact: null,
comment: '',
loading: false
};
@@ -98,9 +118,14 @@ export default class LnurlPay extends React.Component<
}
stateFromProps(props: LnurlPayProps) {
const { route, UnitsStore } = props;
const { route, UnitsStore, ContactStore } = props;
const { resetUnits, units } = UnitsStore;
const { lnurlParams: lnurl, amount, satAmount } = route.params ?? {};
const {
lnurlParams: lnurl,
amount,
satAmount,
lightningAddress
} = route.params ?? {};
// if requested amount is fixed,
// convert units to sats so conversion rate doesn't make things unpayable
@@ -138,10 +163,27 @@ export default class LnurlPay extends React.Component<
finalSatAmount = minSendableSats;
}
// Find matching contact by Lightning Address
let matchedContact: Contact | null = null;
if (lightningAddress && ContactStore?.contacts) {
const normalizedAddress = lightningAddress.toLowerCase();
const found = ContactStore.contacts.find((contact: Contact) =>
contact.lnAddress?.some(
(addr: string) =>
addr && addr.toLowerCase() === normalizedAddress
)
);
if (found) {
matchedContact = new Contact(found);
}
}
return {
amount: finalAmount,
satAmount: finalSatAmount,
domain: lnurl.domain,
lightningAddress: lightningAddress || '',
matchedContact,
comment: ''
};
}
@@ -309,10 +351,40 @@ export default class LnurlPay extends React.Component<
render() {
const { navigation, route } = this.props;
const { amount, satAmount, domain, comment, loading } = this.state;
const {
amount,
satAmount,
domain,
lightningAddress,
matchedContact,
comment,
loading
} = this.state;
const lnurl = route.params?.lnurlParams;
// Extract image from LNURL metadata
let metadataImage: string | null = null;
try {
const keypairs: Array<[string, string]> = JSON.parse(
lnurl.metadata
);
metadataImage =
keypairs
.filter(([typ]) => typ.startsWith('image/'))
.map(([typ, content]) => `data:${typ},${content}`)[0] ||
null;
} catch (err) {
console.log('Error parsing LNURL metadata:', err);
}
// Priority: Contact photo > LNURL metadata image
const displayImage = matchedContact?.photo
? matchedContact.getPhoto
: metadataImage;
const displayName = matchedContact?.name;
const displayAddress = lightningAddress || domain;
return (
<Screen>
<Header
@@ -337,19 +409,40 @@ export default class LnurlPay extends React.Component<
navigation={navigation}
/>
<ScrollView keyboardShouldPersistTaps="handled">
{domain && (
<View style={styles.content}>
{/* Lightning Address Header */}
{displayAddress && (
<View style={styles.headerSection}>
{displayImage && (
<Image
source={{ uri: displayImage }}
style={styles.profileImage}
/>
)}
{displayName && (
<Text
style={[
styles.nameText,
{ color: themeColor('text') }
]}
>
{displayName}
</Text>
)}
<Text
style={{
...styles.text,
color: themeColor('secondaryText'),
padding: 20,
fontWeight: 'bold',
fontSize: 22
}}
style={[
styles.addressText,
{ color: themeColor('text') }
]}
>
{domain}
{displayAddress}
</Text>
<View style={{ marginTop: 8 }}>
<LnurlPayMetadata
metadata={lnurl.metadata}
showArrow={false}
hideImage={true}
/>
</View>
</View>
)}
@@ -484,12 +577,6 @@ export default class LnurlPay extends React.Component<
/>
</View>
</View>
<View style={styles.metadata}>
<LnurlPayMetadata
metadata={lnurl.metadata}
showArrow={false}
/>
</View>
</ScrollView>
</Screen>
);
@@ -502,5 +589,25 @@ const styles = StyleSheet.create({
},
content: { paddingHorizontal: 20 },
button: { paddingVertical: 15 },
metadata: { padding: 20 }
headerSection: {
alignItems: 'center',
paddingVertical: 20,
paddingHorizontal: 20
},
profileImage: {
width: 100,
height: 100,
borderRadius: 50,
marginBottom: 15
},
nameText: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 5,
fontFamily: 'PPNeueMontreal-Book'
},
addressText: {
fontSize: 18,
fontFamily: 'PPNeueMontreal-Book'
}
});
+38 -38
View File
@@ -1,5 +1,5 @@
import * as React from 'react';
import { Text, Image, View, ScrollView } from 'react-native';
import { Text, Image, View } from 'react-native';
import { Icon } from '@rneui/themed';
import { themeColor } from '../../utils/ThemeUtils';
@@ -7,11 +7,12 @@ import { themeColor } from '../../utils/ThemeUtils';
interface LnurlPayMetadataProps {
metadata: string;
showArrow?: boolean;
hideImage?: boolean;
}
export default class LnurlPayMetadata extends React.Component<LnurlPayMetadataProps> {
render() {
const { metadata, showArrow = true } = this.props;
const { metadata, showArrow = true, hideImage = false } = this.props;
let keypairs: Array<Array<string>>;
try {
@@ -28,51 +29,50 @@ export default class LnurlPayMetadata extends React.Component<LnurlPayMetadataPr
.map(([typ, content]: any) => `data:${typ},${content}`)[0];
return (
<ScrollView keyboardShouldPersistTaps="handled">
<View
style={{
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center'
}}
>
{image && !hideImage ? (
<Image
style={{
width: 90,
height: 90,
marginBottom: 10
}}
source={{ uri: image }}
/>
) : null}
<View
style={{
flexDirection: 'column',
justifyContent: 'center',
flexDirection: 'row',
alignItems: 'center'
}}
>
{image ? (
<Image
style={{
width: 90,
height: 90,
marginBottom: 10
}}
source={{ uri: image }}
/>
) : null}
<View
<Text
style={{
flexDirection: 'row',
alignItems: 'center'
color: showArrow
? themeColor('highlight')
: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book',
textAlign: 'center'
}}
>
<Text
style={{
color: showArrow
? themeColor('highlight')
: themeColor('text'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{text}
</Text>
{showArrow && (
<Icon
name="keyboard-arrow-down"
type="material"
size={15}
color={themeColor('highlight')}
/>
)}
</View>
{text}
</Text>
{showArrow && (
<Icon
name="keyboard-arrow-down"
type="material"
size={15}
color={themeColor('highlight')}
/>
)}
</View>
</ScrollView>
</View>
);
}
}