Merge branch 'main' into v0.1.7-beta

This commit is contained in:
minibits-cash
2024-05-02 23:22:40 +02:00
14 changed files with 878 additions and 597 deletions
+1 -1
View File
@@ -137,7 +137,7 @@ Minibits wallet is in early beta and available as of now only for Android device
Minibits is a bare React Native app written in Typescript. The project structure and code itself are intentionally verbose to support readability. Critical wallet code is reasonably documented. However, there is vast space for existing code improvements, refactoring, and bug fixing. This is an early beta software and the author does not code for a living.
The code is derived from Ignite template, however with many libraries, notably Expo, stripped down to achieve fast startup times. Performance bottleneck on some Android devices is react-native-keychain. To overcome this, it has been patched not to warm-up on startup, caching for wallet operatoions is in place and its use to encrypt storage is opt-in.
The code is derived from Ignite template, however with many libraries, notably Expo, stripped down to achieve fast startup times. Performance bottleneck on some Android devices is react-native-keychain. To overcome this, it has been patched not to warm-up on startup, caching for wallet operations is in place and its use to encrypt storage is opt-in.
Wallet state is managed by mobx-state-tree and persisted in fast MMKV storage. Only the basic mobx concepts are in place, whole model could be improved. All critical wallet code is in services/walletService.ts and all ecash state changes are in models/ProofsStore.ts. Wallet communication with the mints is in services/cashuMintClient.ts and uses [cashu-ts](https://github.com/cashubtc/cashu-ts) library.
+108 -1
View File
@@ -7,11 +7,47 @@ const en = {
paste: "Paste",
scan: "Scan",
copy: "Copy",
copyFailParam: "Couldn't copy %{param}",
confirm: "Confirm",
save: "Save",
add: "Add",
success: "Success!",
offline: "Offline",
qr: "QR Code",
sats: "SATS",
notCreated: 'Not yet created',
resetDefault: 'Reset to default',
share: "Share",
},
paymentCommon: {
pay: "Pay",
payMe: 'Pay me',
paySatsAmountTo: "Pay %{amount} SATS to",
payFromWallet: 'Pay from wallet',
receiveInPerson: "Receive in person"
},
transactionCommon: {
from: "From %{sender}",
receivedFrom: "Received from %{sender}",
sentTo: "Sent to %{receiver}",
paidTo: "Paid to %{receiver}",
youReceived: 'You received',
youSent: 'You sent',
youPaid: 'You paid',
unknown: 'Unknown transaction',
status: {
completedFee: 'Completed %{fee}',
draft: 'Draft',
error: 'Error',
pending: 'Pending',
prepared: 'Prepared',
reverted: 'Reverted',
blocked: 'Blocked',
expired: 'Expired'
},
tapToRedeem: 'Tap to redeem',
redeemOnline: 'Redeem online'
},
welcomeScreen: {
page1: {
heading: "Welcome",
@@ -48,6 +84,10 @@ const en = {
topUpWalletSubText: "Top up your balance by paying Bitcoin lightning invoice from another wallet",
transferFromWallet: "Transfer from wallet",
transferFromWalletSubText: "Debit your balance by paying Bitcoin lightning invoice from this wallet",
feeBadge: {
final: "+ final fee %{fee} SATS",
upto: "+ fee up to %{fee} SATS"
}
},
receiveScreen: {
title: "Receive",
@@ -112,8 +152,75 @@ const en = {
contactsScreen: {
new: "New",
scan: "Scan",
newTitle: "Add new contact",
newTitle: "Add new contact",
getInvoice: "Get invoice",
ownName: {
donationSuccess: "Thank you! Donation for %{receiver} has been successfully paid.",
tooShort: 'Write your wallet profile name to the text box, use min 2 characters.',
illegalChar: 'Do not use . or - characters at the beginning or the end of name.',
profileExists: 'This wallet name is already in use, choose another one.',
chooseOwnName: 'Choose your own name',
chooseOwnNameFooter: 'Use lowercase letters, numbers and .-_',
available: "%{name} is available!",
payToGetOwnName: "Pay the following lightning invoice and get your %{name} wallet name.",
ctaPay: "Pay from wallet",
insufficient: 'Your wallet balance is not enough to pay this invoice amount but you can still pay it from another wallet.',
betaWarning: 'Please accept this is an early beta software. Your data can still be lost due to a bug or unexpected data loss.'
},
privateContacts: {
selectSendPaymentRequest: 'Select contact to send your payment request to.',
selectSendToken: 'Select contact to send your ecash to.',
selectSendLnURL: 'Select contact to send Lightning payment to.',
saveNewFormat: "Please enter a wallet address in name@domain.xyz format",
profileNotFound: "Profile name %{name} could not be found. Make sure the name is correct.",
noLightningAddress: 'This contact does not have a Lightning address, send ecash instead.',
explainerText: "Private contacts",
explainerSubText: "Add other Minibits users as your private contacts. Every user gets sharable @minibits.cash wallet address. You can pay privately to your contacts anytime even if they are offline.",
switchName: "Switch your wallet name and picture?",
switchNameSubText: 'Get cooler wallet name or profile picture. Select from an array of random names and images or opt for your own @minibits.cash wallet name.',
bottomModal: "Private contacts are unique identifiers of other Minibits wallets. You can use them to send or request ecash and you can safely share your own with others.",
domainMinibits: "Use minibits.cash domain",
domainExternal: "Use another domain"
},
publicContacts: {
npubPasteError: 'Copy your NPUB key first, then paste',
relayurlPasteError: 'Copy your relay URL key first, then paste',
relayExists: 'Relay already exists.',
missingLightningAddress: 'This contact does not have a Lightning address, send ecash instead.',
nostrTip: 'Tip the people you follow',
nostrTipSubText: 'Add your NOSTR social network public key (npub) and tip or donate to your favourite people and projects directly from the minibits wallet.',
nostrSetPublicKey: 'Set your public key',
nostrSetPublicKeySubText: 'Add or change your NOSTR social network public key (npub).',
nostrSetRelay: 'Set relay',
nostrSetRelaySubText: 'Add or change your own relay if your profile and follows are not hosted on the default relays.',
nostrRemovePub: 'Remove your public key',
nostrRemovePubSubText: 'Remove your npub key and stop loading public contacts.',
addNpub: 'Add your npub key',
pasteDemoKey: 'Paste demo key',
setOwnRelay: 'Set your own relay'
},
randomName: {
selectOneOfUsernames: 'Select one of the usernames'
}
},
paymentRequestScreen: {
incoming: {
noRequests: 'There are no incoming payment requests to be paid or they have already expired.'
},
outgoing: {
noRequests: 'There are no outgoing payment requests to be paid or they have already expired.',
invoiceShared: 'Lightning invoice has been shared, waiting to be paid by receiver.',
sharingCancelled: 'Sharing cancelled',
scanAndPay: 'Scan and pay to top-up'
},
// might move this to common later if it gets used in other screens
listItem: {
expires: "Expires %{datetime}",
expired: "Expired %{datetime}",
requestPaymentSuccess: 'Request has been paid'
}
},
profileScreen: {
changeAvatar: "Change picture",
changeAvatarSubtext: "Select one from randomly generated pictures.",
+21 -14
View File
@@ -1,7 +1,6 @@
import { I18nOptions } from "i18n-js"
import { i18n } from "./i18n"
import { TxKeyPath } from "./i18n"
import {I18nOptions} from 'i18n-js'
import {i18n} from './i18n'
import {TxKeyPath} from './i18n'
/**
* Translates text.
@@ -12,21 +11,29 @@ import { TxKeyPath } from "./i18n"
*
* @example
* Translations:
*
* ```en.ts
* ```ts
* // en.ts
* {
* "hello": "Hello, {{name}}!"
* "heading": "Welcome to the app!",
* "buttonText": "Donate",
* "greeting": "Hello, %{name}!" // parameter
* }
* ```
*
* Usage:
* ```ts
* import { translate } from "i18n-js"
* ```tsx
* import { translate } from "../../i18n";
* // or whatever the path is, let it auto-import
*
* translate("common.ok", { name: "world" })
* // => "Hello world!"
* translate("heading") // => Welcome to the app!
* translate("greeting", { name: "world" }) // => Hello world!
* <Button tx="buttonText" /> // => a Donate button
* ```
* You can use nested objects, access them like: `common.payment.success`
*/
export function translate(key: TxKeyPath, options?: I18nOptions) {
return i18n.t(key, options)
export function translate(
key: TxKeyPath,
options?: Partial<I18nOptions> & {[parameter: string]: any},
) {
return i18n.t(key, options as I18nOptions)
}
+20 -19
View File
@@ -14,6 +14,7 @@ import { ResultModalInfo } from '../Wallet/ResultModalInfo'
import { MINIBITS_NIP05_DOMAIN } from '@env'
import { useFocusEffect } from '@react-navigation/native'
import { SendOption } from '../SendOptionsScreen'
import { translate } from '../../i18n'
const DEFAULT_DONATION_AMOUNT = 100
@@ -101,7 +102,7 @@ export const OwnName = observer(function (props: {navigation: any, pubkey: strin
stopPolling(`checkDonationPaidPoller-${donationInvoice.payment_hash}`)
setResultModalInfo({
status: TransactionStatus.COMPLETED,
message: `Thank you! Donation for ${ownName+MINIBITS_NIP05_DOMAIN} has been successfully paid.`
message: translate("contactsScreen.ownName.donationSuccess", { receiver: ownName+MINIBITS_NIP05_DOMAIN })
})
toggleResultModal()
resetState()
@@ -150,12 +151,12 @@ export const OwnName = observer(function (props: {navigation: any, pubkey: strin
const onOwnNameCheck = async function () {
if(!ownName || ownName.length < 2) {
setInfo('Write your wallet profile name to the text box, use min 2 characters.')
setInfo(translate('contactsScreen.ownName.tooShort'))
return
}
if(!isValidName(ownName)) {
setInfo('Do not use . or - characters at the beginning or the end of name.')
setInfo(translate('contactsScreen.ownName.illegalChar'))
return
}
@@ -163,7 +164,7 @@ export const OwnName = observer(function (props: {navigation: any, pubkey: strin
const profileExists = await MinibitsClient.getWalletProfileByNip05(ownName + MINIBITS_NIP05_DOMAIN)
if(profileExists) {
setInfo('This wallet name is already in use, choose another one.')
setInfo(translate("contactsScreen.ownName.profileExists"))
return
}
setIsChecked(true)
@@ -265,7 +266,7 @@ export const OwnName = observer(function (props: {navigation: any, pubkey: strin
<View style={$contentContainer}>
<Card
style={[$card, {marginTop: spacing.small}]}
heading='Choose your own name'
headingTx='contactsScreen.ownName.chooseOwnName'
headingStyle={{textAlign: 'center'}}
ContentComponent={
<View style={$ownNameContainer}>
@@ -292,7 +293,7 @@ export const OwnName = observer(function (props: {navigation: any, pubkey: strin
/>
</View>
}
footer={'Use lowercase letters, numbers and .-_' }
footerTx='contactsScreen.ownName.chooseOwnNameFooter'
footerStyle={{color: hint, textAlign: 'center'}}
/>
</View>
@@ -305,13 +306,13 @@ export const OwnName = observer(function (props: {navigation: any, pubkey: strin
</View>
<View style={{alignItems: 'center'}}>
<Text
text={`${ownName} is available!`}
text={translate('contactsScreen.ownName.available',{ name: ownName })}
style={{fontSize: 18}}
/>
{donationInvoice ? (
<>
<Text
text={`Pay the following lightning invoice and get your ${ownName+MINIBITS_NIP05_DOMAIN} wallet name.`}
text={translate("contactsScreen.ownName.payToGetOwnName", { name: ownName+MINIBITS_NIP05_DOMAIN })}
style={[$supportText, {color: hint}]}
/>
{isQRcodeVisible ? (
@@ -341,12 +342,12 @@ export const OwnName = observer(function (props: {navigation: any, pubkey: strin
<Button
preset="default"
style={{marginRight: spacing.small}}
text="Pay from wallet"
tx='contactsScreen.ownName.ctaPay'
onPress={onPayDonation}
/>
<Button
preset="secondary"
text="Cancel"
tx='common.cancel'
onPress={resetState}
/>
</View>
@@ -355,14 +356,14 @@ export const OwnName = observer(function (props: {navigation: any, pubkey: strin
<Text
size='xs'
style={{textAlign: 'center', margin: spacing.medium}}
text='Your wallet balance is not enough to pay this invoice amount but you can still pay it from another wallet.'
tx="contactsScreen.ownName.insufficient"
/>
<View style={$payButtonContainer}>
{isQRcodeVisible ? (
<Button
preset="default"
style={{marginRight: spacing.small, maxHeight: 50}}
text="Back"
tx='common.back'
onPress={() => setIsQRCodeVisible(false)}
/>
@@ -377,13 +378,13 @@ export const OwnName = observer(function (props: {navigation: any, pubkey: strin
size={spacing.small}
/>
)}
text="QR code"
tx='common.qr'
onPress={() => setIsQRCodeVisible(true)}
/>
)}
<Button
preset="secondary"
text="Cancel"
tx='common.cancel'
onPress={resetState}
/>
</View>
@@ -405,7 +406,7 @@ export const OwnName = observer(function (props: {navigation: any, pubkey: strin
preset='heading'
/>
<Text
text={`SATS`}
tx='common.sats'
style={{fontSize: 18, marginLeft: spacing.extraSmall}}
/>
</View>
@@ -444,18 +445,18 @@ export const OwnName = observer(function (props: {navigation: any, pubkey: strin
<Button
preset="default"
style={{marginRight: spacing.small}}
text="Get invoice"
tx='contactsScreen.getInvoice'
onPress={onCreateDonation}
/>
<Button
preset="secondary"
text="Cancel"
tx='common.cancel'
onPress={resetState}
/>
</View>
<View style={{flexDirection: 'row', alignItems: 'center', margin: spacing.medium}}>
<Icon icon='faInfoCircle' />
<Text style={{color: hint}} size='xxs' text='Please accept this is an early beta software. Your data can still be lost due to a bug or unexpected data loss.'/>
<Text style={{color: hint}} size='xxs' tx='contactsScreen.ownName.betaWarning'/>
</View>
</>
)}
@@ -474,7 +475,7 @@ export const OwnName = observer(function (props: {navigation: any, pubkey: strin
<ResultModalInfo
icon="faCheckCircle"
iconColor={colors.palette.success200}
title="Success!"
title={translate("common.success")}
message={resultModalInfo?.message as string}
/>
<View style={$payButtonContainer}>
+119 -94
View File
@@ -17,6 +17,7 @@ import { ReceiveOption } from '../ReceiveOptionsScreen'
import { SendOption } from '../SendOptionsScreen'
import { infoMessage, warningMessage } from '../../utils/utils'
import { IncomingDataType, IncomingParser } from '../../services/incomingParser'
import { translate } from '../../i18n'
import { RouteProp } from '@react-navigation/native'
@@ -81,14 +82,13 @@ export const PrivateContacts = observer(function (props: {
log.trace('Start', newContactName, 'saveNewContact')
if(!newContactName) {
setInfo(`Please enter a wallet address in name@domain.xyz format`)
setInfo(translate("contactsScreen.privateContacts.saveNewFormat"))
return
}
toggleNewContactModal() // close
setIsLoading(true)
try {
let newContact: Contact | undefined = undefined
@@ -118,7 +118,7 @@ export const PrivateContacts = observer(function (props: {
const profileRecord = await MinibitsClient.getWalletProfileByNip05(newContactName + MINIBITS_NIP05_DOMAIN)
if(!profileRecord) {
warningMessage(`Profile name ${newContactName + MINIBITS_NIP05_DOMAIN} could not be found. Make sure the name is correct.`)
warningMessage(translate("contactsScreen.privateContacts.profileNotFound", { name: newContactName + MINIBITS_NIP05_DOMAIN }))
setIsLoading(false)
return
}
@@ -204,7 +204,7 @@ export const PrivateContacts = observer(function (props: {
if(paymentOption && paymentOption === SendOption.LNURL_ADDRESS) {
if(!contact.lud16) {
setInfo('This contact does not have a Lightning address, send ecash instead.')
setInfo(translate("contactsScreen.privateContacts.noLightningAddress"))
return
}
@@ -248,57 +248,55 @@ export const PrivateContacts = observer(function (props: {
const iconBottom = useThemeColor('button')
return (
<Screen contentContainerStyle={$screen}>
<Screen contentContainerStyle={$screen}>
<View style={$contentContainer}>
{contactsStore.count > 0 ? (
{contactsStore.count > 0 ? (
<Card
ContentComponent={
<>
<FlatList<Contact>
data={contactsStore.all as Contact[]}
renderItem={({ item, index }) => {
return(
<ContactListItem
contact={item}
isFirst={index === 0}
gotoContactDetail={() => gotoContactDetail(item)}
/>
)
}}
keyExtractor={(item) => item.pubkey}
style={{ flexGrow: 0 }}
/>
</>
}
style={$card}
ContentComponent={
<FlatList<Contact>
data={contactsStore.all as Contact[]}
renderItem={({item, index}) => {
return (
<ContactListItem
contact={item}
isFirst={index === 0}
gotoContactDetail={() => gotoContactDetail(item)}
/>
)
}}
keyExtractor={item => item.pubkey}
style={{flexGrow: 0}}
/>
}
style={$card}
/>
) : (
<Card
ContentComponent={
<>
<ListItem
leftIcon='faComment'
leftIconInverse={true}
leftIconColor={colors.palette.iconGreen200}
text='Private contacts'
subText={"Add other Minibits users as your private contacts. Every user gets sharable @minibits.cash wallet address. You can pay privately to your contacts anytime even if they are offline."}
onPress={gotoNew}
/>
<ListItem
leftIcon='faCircleUser'
leftIconInverse={true}
leftIconColor={colors.palette.iconMagenta200}
text="Switch your wallet name and picture?"
subText={"Get cooler wallet name or profile picture. Select from an array of random names and images or opt for your own @minibits.cash wallet name."}
onPress={gotoProfile}
topSeparator={true}
/>
</>
}
style={$card}
/>
)}
{isLoading && <Loading />}
) : (
<Card
ContentComponent={
<>
<ListItem
leftIcon="faComment"
leftIconInverse={true}
leftIconColor={colors.palette.iconGreen200}
tx='contactsScreen.privateContacts.explainerText'
subTx="contactsScreen.privateContacts.explainerSubText"
onPress={gotoNew}
/>
<ListItem
leftIcon="faCircleUser"
leftIconInverse={true}
leftIconColor={colors.palette.iconMagenta200}
tx="contactsScreen.privateContacts.switchName"
subTx='contactsScreen.privateContacts.switchNameSubText'
onPress={gotoProfile}
topSeparator={true}
/>
</>
}
style={$card}
/>
)}
{isLoading && <Loading />}
</View>
<View style={$bottomContainer}>
<View style={$buttonContainer}>
@@ -317,51 +315,78 @@ export const PrivateContacts = observer(function (props: {
</View>
</View>
<BottomModal
isVisible={isNewContactModalVisible ? true : false}
ContentComponent={
<View style={$newContainer}>
<Text tx='contactsScreen.newTitle' preset="subheading" />
<Text size='xxs' style={{color: domainText}} text='Private contacts are unique identifiers of other Minibits wallets. You can use them to send or request ecash and you can safely share your own with others.' />
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: spacing.small}}>
<TextInput
ref={contactNameInputRef}
onChangeText={newContactName => setNewContactName(newContactName)}
value={newContactName}
autoCapitalize='none'
keyboardType='default'
maxLength={60}
selectTextOnFocus={true}
style={[$contactInput, {backgroundColor: inputBg}, (isExternalDomain) && {marginRight: spacing.small, borderTopRightRadius: spacing.small, borderBottomRightRadius: spacing.small}]}
/>
{!isExternalDomain && (
<View style={[$contactDomain, { backgroundColor: inputBg}]}>
<Text size='xxs' style={{color: domainText}} text={MINIBITS_NIP05_DOMAIN}/>
</View>
)}
<Button
tx={'common.save'}
style={{
borderRadius: spacing.small,
marginRight: spacing.small,
}}
onPress={saveNewContact}
/>
</View>
<Button
preset='tertiary'
text={isExternalDomain ? 'Use minibits.cash domain' : 'Use another domain'}
onPress={toggleExternalDomain}
style={{alignSelf: 'flex-start', minHeight: verticalScale(30)}}
textStyle={{lineHeight: verticalScale(16), fontSize: 12}}
isVisible={isNewContactModalVisible ? true : false}
ContentComponent={
<View style={$newContainer}>
<Text tx="contactsScreen.newTitle" preset="subheading" />
<Text
size="xxs"
style={{color: domainText}}
tx="contactsScreen.privateContacts.bottomModal"
/>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.small,
}}>
<TextInput
ref={contactNameInputRef}
onChangeText={newContactName =>
setNewContactName(newContactName)
}
value={newContactName}
autoCapitalize="none"
keyboardType="default"
maxLength={60}
selectTextOnFocus={true}
style={[
$contactInput,
{backgroundColor: inputBg},
isExternalDomain && {
marginRight: spacing.small,
borderTopRightRadius: spacing.small,
borderBottomRightRadius: spacing.small,
},
]}
/>
{!isExternalDomain && (
<View style={[$contactDomain, {backgroundColor: inputBg}]}>
<Text
size="xxs"
style={{color: domainText}}
text={MINIBITS_NIP05_DOMAIN}
/>
</View>
}
onBackButtonPress={toggleNewContactModal}
onBackdropPress={toggleNewContactModal}
/>
</View>
)}
<Button
tx={'common.save'}
style={{
borderRadius: spacing.small,
marginRight: spacing.small,
}}
onPress={saveNewContact}
/>
</View>
<Button
preset="tertiary"
tx={
isExternalDomain
? 'contactsScreen.privateContacts.domainMinibits'
: 'contactsScreen.privateContacts.domainExternal'
}
onPress={toggleExternalDomain}
style={{alignSelf: 'flex-start', minHeight: verticalScale(30)}}
textStyle={{lineHeight: verticalScale(16), fontSize: 12}}
/>
</View>
}
onBackButtonPress={toggleNewContactModal}
onBackdropPress={toggleNewContactModal}
/>
{info && <InfoModal message={info} />}
{error && <ErrorModal error={error} />}
</Screen>
</Screen>
)
})
+2 -1
View File
@@ -6,6 +6,7 @@ import { Icon, Text } from "../../components"
import { useStores } from "../../models"
import { spacing, useThemeColor } from "../../theme"
import { getImageSource } from '../../utils/utils'
import { translate } from "../../i18n"
export interface ProfileHeaderProps {
@@ -36,7 +37,7 @@ export const ProfileHeader = observer(function (props: ProfileHeaderProps) {
color={'white'}
/>
)}
<Text preset='bold' text={nip05 || 'Not yet created'} style={{color: 'white', marginBottom: spacing.small}} />
<Text preset='bold' text={nip05 || translate("common.notCreated")} style={{color: 'white', marginBottom: spacing.small}} />
</View>
)
})
+21 -20
View File
@@ -17,6 +17,7 @@ import { ReceiveOption } from '../ReceiveOptionsScreen'
import { useSafeAreaInsetsStyle } from '../../utils/useSafeAreaInsetsStyle'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { IncomingDataType, IncomingParser } from '../../services/incomingParser'
import { translate } from '../../i18n'
import { RouteProp } from '@react-navigation/native'
@@ -150,8 +151,8 @@ export const PublicContacts = observer(function (props: {
if(event.kind === 3) {
const pubkeys = event.tags
.filter((item: [string, string]) => item[0] === "p")
.map((item: [string, string]) => item[1])
.filter((item) => item[0] === "p")
.map((item) => item[1])
log.trace('Following pubkeys:', pubkeys.length)
setFollowingPubkeys(pubkeys)
@@ -214,7 +215,7 @@ export const PublicContacts = observer(function (props: {
const onPastePublicPubkey = async function () {
const key = await Clipboard.getString()
if (!key) {
setInfo('Copy your NPUB key first, then paste')
setInfo(translate("contactsScreen.publicContacts.npubPasteError"))
return
}
setNewPublicPubkey(key)
@@ -264,7 +265,7 @@ export const PublicContacts = observer(function (props: {
const onPastePublicRelay = async function () {
const url = await Clipboard.getString()
if (!url) {
setInfo('Copy your relay URL key first, then paste')
setInfo(translate("contactsScreen.publicContacts.relayurlPasteError"))
return
}
setNewPublicRelay(url)
@@ -275,7 +276,7 @@ export const PublicContacts = observer(function (props: {
try {
if(newPublicRelay) {
if(relaysStore.alreadyExists(newPublicRelay)) {
setInfo('Relay already exists.')
setInfo(translate("contactsScreen.publicContacts.relayExists"))
return
}
@@ -380,7 +381,7 @@ export const PublicContacts = observer(function (props: {
if(paymentOption && paymentOption === SendOption.LNURL_ADDRESS) {
if(!contact.lud16) {
setInfo('This contact does not have a Lightning address, send ecash instead.')
setInfo(translate('contactsScreen.publicContacts.missingLightningAddress'))
//reset
navigation.setParams({
paymentOption: undefined,
@@ -439,8 +440,8 @@ export const PublicContacts = observer(function (props: {
leftIcon='faComment'
leftIconInverse={true}
leftIconColor={colors.palette.iconViolet200}
text='Tip the people you follow'
subText={'Add your NOSTR social network public key (npub) and tip or donate to your favourite people and projects directly from the minibits wallet.'}
tx="contactsScreen.publicContacts.nostrTip"
subTx='contactsScreen.publicContacts.nostrTipSubText'
onPress={toggleNpubModal}
/>
}
@@ -522,22 +523,22 @@ export const PublicContacts = observer(function (props: {
<>
<ListItem
leftIcon='faKey'
text='Set your public key'
subText={'Add or change your NOSTR social network public key (npub).'}
tx="contactsScreen.publicContacts.nostrSetPublicKey"
subTx='contactsScreen.publicContacts.nostrSetPublicKeySubText'
onPress={toggleNpubModal}
bottomSeparator={true}
/>
<ListItem
leftIcon='faCircleNodes'
text='Set relay'
subText={'Add or change your own relay if your profile and follows are not hosted on the default relays.'}
tx='contactsScreen.publicContacts.nostrSetRelay'
subTx='contactsScreen.publicContacts.nostrSetRelaySubText'
onPress={toggleRelayModal}
bottomSeparator={true}
/>
<ListItem
leftIcon='faBan'
text='Remove your public key'
subText={'Remove your npub key and stop loading public contacts.'}
tx="contactsScreen.publicContacts.nostrRemovePub"
subTx="contactsScreen.publicContacts.nostrRemovePubSubText"
onPress={onRemovePublicPubKey}
/>
</>
@@ -549,7 +550,7 @@ export const PublicContacts = observer(function (props: {
isVisible={isNpubModalVisible}
ContentComponent={
<View style={$newContainer}>
<Text text='Add your npub key' preset="subheading" />
<Text tx="contactsScreen.publicContacts.addNpub" preset="subheading" />
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: spacing.small}}>
<TextInput
ref={npubInputRef}
@@ -575,8 +576,8 @@ export const PublicContacts = observer(function (props: {
/>
</View>
<View style={[$buttonContainer, {marginTop: spacing.medium}]}>
<Button preset='tertiary' onPress={() => setNewPublicPubkey(defaultPublicNpub)} text='Paste demo key'/>
<Button preset='tertiary' onPress={toggleNpubModal} text='Cancel'/>
<Button preset='tertiary' onPress={() => setNewPublicPubkey(defaultPublicNpub)} tx="contactsScreen.publicContacts.pasteDemoKey"/>
<Button preset='tertiary' onPress={toggleNpubModal} tx="common.cancel"/>
</View>
</View>
}
@@ -587,7 +588,7 @@ export const PublicContacts = observer(function (props: {
isVisible={isRelayModalVisible ? true : false}
ContentComponent={
<View style={$newContainer}>
<Text text='Set your own relay' preset="subheading" />
<Text tx="contactsScreen.publicContacts.setOwnRelay" preset="subheading" />
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: spacing.small}}>
<TextInput
ref={relayInputRef}
@@ -614,9 +615,9 @@ export const PublicContacts = observer(function (props: {
</View>
<View style={[$buttonContainer, {marginTop: spacing.medium}]}>
{newPublicRelay && (
<Button preset='tertiary' onPress={onRemovePublicRelay} text='Reset to default'/>
<Button preset='tertiary' onPress={onRemovePublicRelay} tx="common.resetDefault"/>
)}
<Button preset='tertiary' onPress={toggleRelayModal} text='Cancel'/>
<Button preset='tertiary' onPress={toggleRelayModal} tx="common.cancel"/>
</View>
</View>
}
+125 -114
View File
@@ -1,140 +1,151 @@
import {observer} from 'mobx-react-lite'
import React, {useEffect, useState} from 'react'
import {TextStyle, View, ViewStyle, InteractionManager, FlatList } from 'react-native'
import {
TextStyle,
View,
ViewStyle,
InteractionManager,
FlatList,
} from 'react-native'
import {spacing} from '../../theme'
import {Button, Card, ErrorModal, InfoModal, Loading, Screen, Text} from '../../components'
import {
Button,
Card,
ErrorModal,
InfoModal,
Loading,
Screen,
Text,
} from '../../components'
import {useStores} from '../../models'
import AppError, { Err } from '../../utils/AppError'
import AppError, {Err} from '../../utils/AppError'
import {$sizeStyles} from '../../components/Text'
import {getRandomUsername} from '../../utils/usernames'
import { scale } from '@gocodingnow/rn-size-matters'
import {scale} from '@gocodingnow/rn-size-matters'
import {translate} from '../../i18n'
export const RandomName = observer(function (props: {
navigation: any
pubkey: string
}) {
// const navigation = useNavigation()
const {userSettingsStore, walletProfileStore} = useStores()
const {navigation} = props
export const RandomName = observer(function (props: {navigation: any, pubkey: string}) {
// const navigation = useNavigation()
const {userSettingsStore, walletProfileStore} = useStores()
const {navigation} = props
const [randomNames, setRandomNames] = useState<string[]>([])
const [selectedName, setSelectedName] = useState<string>('')
const [info, setInfo] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<AppError | undefined>()
const [randomNames, setRandomNames] = useState<string[]>([])
const [selectedName, setSelectedName] = useState<string>('')
const [info, setInfo] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<AppError | undefined>()
useEffect(() => {
const load = async () => {
setIsLoading(true)
InteractionManager.runAfterInteractions(async () => {
let i = 0
let names = []
while(i < 8) {
const name = getRandomUsername()
names.push(name)
i++
}
setRandomNames(names)
setIsLoading(false)
})
useEffect(() => {
const load = async () => {
setIsLoading(true)
InteractionManager.runAfterInteractions(async () => {
let i = 0
let names = []
while (i < 8) {
const name = getRandomUsername()
names.push(name)
i++
}
load()
return () => {}
}, [])
const confirmSelectedName = async function () {
if(!selectedName) {
setInfo('Select one of the usernames')
return
}
try {
setIsLoading(true)
await walletProfileStore.updateName(selectedName)
setIsLoading(false)
navigation.goBack()
return
} catch (e: any) {
handleError(e)
}
}
const handleError = function (e: AppError): void {
setRandomNames(names)
setIsLoading(false)
setError(e)
})
}
//
return (
<Screen contentContainerStyle={$screen}>
<View style={$contentContainer}>
<Card
ContentComponent={
<>
<FlatList
data={randomNames}
numColumns={2}
renderItem={({ item, index }) => {
return(
<Button
key={index}
preset={selectedName === item ? 'default' : 'secondary'}
onPress={() => setSelectedName(item)}
text={`${item}`}
style={{minWidth: scale(150), margin: spacing.extraSmall}}
textStyle={$sizeStyles.xs}
/>
)
}}
keyExtractor={(item) => item}
style={{ flexGrow: 0 }}
/>
</>
}
style={$card}
/>
<View style={$buttonContainer}>
<Button
onPress={() => confirmSelectedName()}
text={'Confirm'}
style={{marginTop: spacing.medium, minWidth: 120}}
/>
</View>
load()
return () => {}
}, [])
const confirmSelectedName = async function () {
if (!selectedName) {
setInfo(translate('contactsScreen.randomName.selectOneOfUsernames'))
return
}
try {
setIsLoading(true)
await walletProfileStore.updateName(selectedName)
setIsLoading(false)
navigation.goBack()
return
} catch (e: any) {
handleError(e)
}
}
const handleError = function (e: AppError): void {
setIsLoading(false)
setError(e)
}
//
return (
<Screen contentContainerStyle={$screen}>
<View style={$contentContainer}>
<Card
ContentComponent={
<FlatList
data={randomNames}
numColumns={2}
renderItem={({item, index}) => {
return (
<Button
key={index}
preset={selectedName === item ? 'default' : 'secondary'}
onPress={() => setSelectedName(item)}
text={`${item}`}
style={{minWidth: scale(150), margin: spacing.extraSmall}}
textStyle={$sizeStyles.xs}
/>
)
}}
keyExtractor={item => item}
style={{flexGrow: 0}}
/>
}
style={$card}
/>
<View style={$buttonContainer}>
<Button
onPress={() => confirmSelectedName()}
tx="common.confirm"
style={{marginTop: spacing.medium, minWidth: 120}}
/>
</View>
{error && <ErrorModal error={error} />}
{info && <InfoModal message={info} />}
{isLoading && <Loading/>}
</Screen>
)
})
</View>
{error && <ErrorModal error={error} />}
{info && <InfoModal message={info} />}
{isLoading && <Loading />}
</Screen>
)
})
const $screen: ViewStyle = {}
const $contentContainer: TextStyle = {
padding: spacing.extraSmall,
const $contentContainer: TextStyle = {
padding: spacing.extraSmall,
}
const $buttonContainer: ViewStyle = {
flexDirection: 'row',
alignSelf: 'center',
flexDirection: 'row',
alignSelf: 'center',
}
const $bottomContainer: ViewStyle = {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
flex: 1,
justifyContent: 'flex-end',
marginBottom: spacing.medium,
alignSelf: 'stretch',
// opacity: 0,
}
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
flex: 1,
justifyContent: 'flex-end',
marginBottom: spacing.medium,
alignSelf: 'stretch',
// opacity: 0,
}
const $card: ViewStyle = {
marginBottom: 0,
marginBottom: 0,
}
@@ -72,7 +72,7 @@ export const IncomingRequests = observer(function (props: {
/>
) : (
<Card
content={'There are no incoming payment requests to be paid or they have already expired.'}
contentTx='paymentRequestScreen.incoming.noRequests'
contentStyle={{color: hintColor, padding: spacing.small}}
style={$card}
/>
@@ -29,6 +29,7 @@ import { PaymentRequestListItem } from './PaymentRequestListItem'
import QRCode from 'react-native-qrcode-svg'
import { infoMessage } from '../../utils/utils'
import Clipboard from '@react-native-clipboard/clipboard'
import { translate } from '../../i18n'
export const OutgoingRequests = observer(function (props: {
@@ -73,11 +74,11 @@ export const OutgoingRequests = observer(function (props: {
if (result.action === Share.sharedAction) {
setTimeout(
() => infoMessage('Lightning invoice has been shared, waiting to be paid by receiver.'),
() => infoMessage(translate("paymentRequestScreen.outgoing.invoiceShared")),
500,
)
} else if (result.action === Share.dismissedAction) {
infoMessage('Sharing cancelled')
infoMessage(translate("paymentRequestScreen.outgoing.sharingCancelled"))
}
} catch (e: any) {
handleError(e)
@@ -89,7 +90,7 @@ export const OutgoingRequests = observer(function (props: {
try {
Clipboard.setString(selectedRequest?.encodedInvoice as string)
} catch (e: any) {
setInfo(`Could not copy: ${e.message}`)
setInfo(translate("common.copyFailParam", { param: e.message }))
}
}
@@ -127,7 +128,7 @@ export const OutgoingRequests = observer(function (props: {
) : (
<Card
content={'There are no outgoing payment requests to be paid or they have already expired.'}
contentTx="paymentRequestScreen.outgoing.noRequests"
contentStyle={{color: hintColor, padding: spacing.small}}
style={$card}
/>
@@ -165,7 +166,7 @@ const ShareAsQRCodeBlock = observer(function (props: {
}) {
return (
<View style={[$bottomModal, {marginHorizontal: spacing.small}]}>
<Text text={'Scan and pay to top-up'} />
<Text tx="paymentRequestScreen.outgoing.scanAndPay" />
<View style={$qrCodeContainer}>
<QRCode
size={270}
@@ -175,7 +176,7 @@ const ShareAsQRCodeBlock = observer(function (props: {
</View>
<View style={$buttonContainer}>
<Button
text="Share"
tx="common.share"
onPress={props.onShareToApp}
style={{marginRight: spacing.medium}}
LeftAccessory={() => (
@@ -187,10 +188,10 @@ const ShareAsQRCodeBlock = observer(function (props: {
/>
)}
/>
<Button preset="secondary" text="Copy" onPress={props.onCopy} />
<Button preset="secondary" tx="common.copy" onPress={props.onCopy} />
<Button
preset="tertiary"
text="Close"
tx="common.close"
onPress={props.toggleQRModal}
/>
</View>
@@ -1,207 +1,303 @@
import {differenceInSeconds} from "date-fns"
import { formatDistance } from 'date-fns'
import { observer } from "mobx-react-lite"
import React from "react"
import { Image, ImageStyle, ScrollView, StyleSheet, TextStyle, View, ViewStyle } from "react-native"
import { Button, Card, Icon, ListItem, Screen, Text } from "../../components"
import { Contact, ContactType } from "../../models/Contact"
import { PaymentRequest, PaymentRequestStatus, PaymentRequestType } from "../../models/PaymentRequest"
import { log, NostrClient } from "../../services"
import { colors, spacing, typography, useThemeColor } from "../../theme"
import { getImageSource } from '../../utils/utils'
import { ContactListItem } from "../Contacts/ContactListItem"
import { SendOption } from "../SendOptionsScreen"
import { CurrencySign } from "../Wallet/CurrencySign"
import { CurrencyCode, MintUnitCurrencyPairs } from "../../services/wallet/currency"
import {MINIBITS_NIP05_DOMAIN, MINIBITS_SERVER_API_HOST} from '@env'
import differenceInSeconds from 'date-fns/differenceInSeconds'
import formatDistance from 'date-fns/formatDistance'
import {observer} from 'mobx-react-lite'
import React from 'react'
import {
Image,
ImageStyle,
ScrollView,
StyleSheet,
TextStyle,
View,
ViewStyle,
} from 'react-native'
import {Button, Card, Icon, ListItem, Screen, Text} from '../../components'
import {Contact, ContactType} from '../../models/Contact'
import {
PaymentRequest,
PaymentRequestStatus,
PaymentRequestType,
} from '../../models/PaymentRequest'
import {log, NostrClient} from '../../services'
import {colors, spacing, typography, useThemeColor} from '../../theme'
import {getImageSource} from '../../utils/utils'
import {ContactListItem} from '../Contacts/ContactListItem'
import {SendOption} from '../SendOptionsScreen'
import { CurrencySign } from '../Wallet/CurrencySign'
import { CurrencyCode, MintUnitCurrencyPairs } from '../../services/wallet/currency'
import {translate} from '../../i18n'
export interface PaymentRequestListProps {
pr: PaymentRequest
isFirst: boolean
isFirst: boolean
navigation: any
onShowQRModal?: any
}
export const PaymentRequestListItem = observer(function (props: PaymentRequestListProps) {
const { pr, isFirst, navigation, onShowQRModal } = props
const hintColor = useThemeColor('textDim')
const secToExpiry = differenceInSeconds(pr.expiresAt as Date, new Date())
const expiryBg = ( secToExpiry < 0 ? colors.palette.angry500 : secToExpiry < 60 ? colors.palette.orange400 : colors.palette.success300)
const separatorColor = useThemeColor('separator')
const onGotoContactDetail = function() {
log.trace(pr)
navigation.navigate('ContactsNavigator', {
screen: 'ContactDetail',
params: {contact: pr.type === PaymentRequestType.INCOMING ? pr.contactFrom : pr.contactTo}
})
}
export const PaymentRequestListItem = observer(function (
props: PaymentRequestListProps,
) {
const {pr, isFirst, navigation, onShowQRModal} = props
const hintColor = useThemeColor('textDim')
const secToExpiry = differenceInSeconds(pr.expiresAt as Date, new Date())
const expiryBg =
secToExpiry < 0
? colors.palette.angry500
: secToExpiry < 60
? colors.palette.orange400
: colors.palette.success300
const separatorColor = useThemeColor('separator')
const onPressPaymentRequest = function() {
if (pr.type === PaymentRequestType.INCOMING) {
navigation.navigate('Transfer', {
paymentRequest: pr,
paymentOption: SendOption.PAY_PAYMENT_REQUEST
})
} else {
if(onShowQRModal) {
onShowQRModal(pr)
const onGotoContactDetail = function () {
log.trace(pr)
navigation.navigate('ContactsNavigator', {
screen: 'ContactDetail',
params: {
contact:
pr.type === PaymentRequestType.INCOMING
? pr.contactFrom
: pr.contactTo,
},
})
}
const onPressPaymentRequest = function () {
if (pr.type === PaymentRequestType.INCOMING) {
navigation.navigate('Transfer', {
paymentRequest: pr,
paymentOption: SendOption.PAY_PAYMENT_REQUEST,
})
} else {
if (onShowQRModal) {
onShowQRModal(pr)
}
}
}
const dim = useThemeColor('textDim')
return (
<Card
HeadingComponent={
<View
style={[
$headerContainer,
{borderBottomColor: separatorColor, borderBottomWidth: 1},
]}>
<Text
tx={
pr.type === PaymentRequestType.INCOMING
? 'paymentCommon.pay'
: 'paymentCommon.payMe'
}
}
}
const dim = useThemeColor('textDim')
return (
<Card
HeadingComponent={
<View style={[$headerContainer, {borderBottomColor: separatorColor, borderBottomWidth: 1}]}>
<Text
text={pr.type === PaymentRequestType.INCOMING ? 'Pay' : 'Pay me'}
/>
<Text preset="heading" text={pr.amount.toLocaleString()} />
<CurrencySign
currencyCode={CurrencyCode.SATS}
containerStyle={{marginBottom: spacing.small}}
/>
</View>
}
ContentComponent={
<>
{pr.contactFrom && pr.contactTo && (
<View
style={{
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
marginVertical: spacing.medium,
}}>
<View
style={{
flexDirection: 'column',
alignItems: 'center',
width: 100,
}}>
<Image
style={[
$profileIcon,
{
width: 40,
height: pr.contactTo?.isExternalDomain ? 40 : 43,
borderRadius: pr.contactTo?.isExternalDomain ? 20 : 0,
},
]}
source={{
uri: getImageSource(pr.contactTo?.picture as string),
}}
/>
<Text
size="xxs"
style={{color: dim}}
text={pr.contactTo?.name}
/>
</View>
<Text
size="xxs"
style={{
color: dim,
textAlign: 'center',
marginLeft: 30,
marginBottom: 20,
}}
text="..........."
/>
<View style={{flexDirection: 'column', alignItems: 'center'}}>
<Icon icon="faPaperPlane" size={spacing.medium} color={dim} />
<Text
size="xxs"
style={{color: dim, marginBottom: -10}}
text={translate('paymentCommon.paySatsAmountTo', {
amount: pr.amount,
})}
/>
</View>
<Text
size="xxs"
style={{
color: dim,
textAlign: 'center',
marginRight: 30,
marginBottom: 20,
}}
text="..........."
/>
<View
style={{
flexDirection: 'column',
alignItems: 'center',
width: 100,
}}>
{pr.contactFrom.picture ? (
<View style={{borderRadius: 20, overflow: 'hidden'}}>
<Image
style={[
$profileIcon,
{
width: 40,
height: pr.contactFrom.isExternalDomain ? 40 : 43,
borderRadius: pr.contactFrom.isExternalDomain
? 20
: 0,
},
]}
source={{
uri: getImageSource(pr.contactFrom.picture as string),
}}
/>
<Text
preset='heading'
text={pr.type === PaymentRequestType.INCOMING ? pr.invoicedAmount.toLocaleString() : pr.amountToTopup.toLocaleString()}
/>
<CurrencySign
mintUnit={pr.type === PaymentRequestType.INCOMING ? pr.invoicedUnit : pr.mintUnit}
containerStyle={{marginBottom: spacing.small}}
/>
</View>
}
ContentComponent={
<>
{pr.contactFrom && pr.contactTo && (
<View style={{flexDirection: 'row', justifyContent: 'space-around', alignItems: 'center', marginVertical: spacing.medium}}>
<View style={{flexDirection: 'column', alignItems: 'center', width: 100}}>
<Image style={[
$profileIcon, {
width: 40,
height: pr.contactTo?.isExternalDomain ? 40 : 43,
borderRadius: pr.contactTo?.isExternalDomain ? 20 : 0,
}]}
source={{
uri: getImageSource(pr.contactTo?.picture as string)
}}
/>
<Text size='xxs' style={{color: dim}} text={pr.contactTo?.name}/>
</View>
<Text size='xxs' style={{color: dim, textAlign: 'center', marginLeft: 30, marginBottom: 20}} text='...........' />
<View style={{flexDirection: 'column', alignItems: 'center'}}>
<Icon
icon='faPaperPlane'
size={spacing.medium}
color={dim}
/>
<Text size='xxs' style={{color: dim, marginBottom: -10}} text={`pay ${pr.invoicedAmount} ${pr.invoicedUnit} to`} />
</View>
<Text size='xxs' style={{color: dim, textAlign: 'center', marginRight: 30, marginBottom: 20}} text='...........' />
<View style={{flexDirection: 'column', alignItems: 'center', width: 100}}>
{pr.contactFrom.picture ? (
<View style={{borderRadius: 20, overflow: 'hidden'}}>
<Image style={[
$profileIcon, {
width: 40,
height: pr.contactFrom.isExternalDomain ? 40 : 43,
borderRadius: pr.contactFrom.isExternalDomain ? 20 : 0,
}]}
source={{
uri: getImageSource(pr.contactFrom.picture as string)
}}
/>
</View>
) : (
<Icon
icon='faCircleUser'
size={38}
color={dim}
/>
)}
<Text size='xxs' style={{color: dim}} text={pr.contactFrom.name}/>
</View>
</View>
)}
{pr.description && (
<View style={{borderBottomColor: separatorColor, borderBottomWidth: 1, paddingLeft: 8}}>
<ListItem
leftIcon="faInfoCircle"
text={pr.description}
textStyle={{fontSize: 14, marginLeft: 8}}
topSeparator={true}
/>
</View>
)}
{pr.status === PaymentRequestStatus.ACTIVE ? (
<Text
style={[$expiry, {backgroundColor: expiryBg}]}
text={`Expire${secToExpiry > 0 ? 's' : 'd'} ${formatDistance(pr.expiresAt as Date, new Date(), {addSuffix: true})}`}
size='xxs'
/>
) : (
<Text
style={[$expiry, {backgroundColor: colors.palette.success300}]}
text={pr.status === PaymentRequestStatus.PAID ? `Request has been paid` : ''}
size='xxs'
/>
)}
</>
}
FooterComponent={
<View style={$buttonContainer}>
{pr.status === PaymentRequestStatus.ACTIVE && (
<Button
preset="secondary"
text={pr.type === PaymentRequestType.INCOMING ? 'Pay from wallet' : 'Receive in person'}
onPress={onPressPaymentRequest}
/>
)}
</View>
}
style={$card}
/>
)
</View>
) : (
<Icon icon="faCircleUser" size={38} color={dim} />
)}
<Text
size="xxs"
style={{color: dim}}
text={pr.contactFrom.name}
/>
</View>
</View>
)}
{pr.description && (
<View
style={{
borderBottomColor: separatorColor,
borderBottomWidth: 1,
paddingLeft: 8,
}}>
<ListItem
leftIcon="faInfoCircle"
text={pr.description}
textStyle={{fontSize: 14, marginLeft: 8}}
topSeparator={true}
/>
</View>
)}
{pr.status === PaymentRequestStatus.ACTIVE ? (
<Text
style={[$expiry, {backgroundColor: expiryBg}]}
text={
translate(
secToExpiry > 0
? 'paymentRequestScreen.listItem.expires'
: 'paymentRequestScreen.listItem.expired',
) +
' ' +
formatDistance(pr.expiresAt as Date, new Date(), {
addSuffix: true,
})
}
size="xxs"
/>
) : (
<Text
style={[$expiry, {backgroundColor: colors.palette.success300}]}
text={
pr.status === PaymentRequestStatus.PAID
? translate("paymentRequestScreen.listItem.requestPaymentSuccess")
: ''
}
size="xxs"
/>
)}
</>
}
FooterComponent={
<View style={$buttonContainer}>
{pr.status === PaymentRequestStatus.ACTIVE && (
<Button
preset="secondary"
tx={
pr.type === PaymentRequestType.INCOMING
? "paymentCommon.payFromWallet"
: 'paymentCommon.receiveInPerson'
}
onPress={onPressPaymentRequest}
/>
)}
</View>
}
style={$card}
/>
)
})
const $profileIcon: ImageStyle = {
padding: spacing.medium,
padding: spacing.medium,
}
const $headerContainer: TextStyle = {
// justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.medium,
// marginBottom: spacing.small,
// height: spacing.screenHeight * 0.18,
}
const $expiry: ViewStyle = {
paddingHorizontal: spacing.small,
borderRadius: spacing.tiny,
alignSelf: 'center',
marginVertical: spacing.small,
// justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.medium,
// marginBottom: spacing.small,
// height: spacing.screenHeight * 0.18,
}
const $expiry: ViewStyle = {
paddingHorizontal: spacing.small,
borderRadius: spacing.tiny,
alignSelf: 'center',
marginVertical: spacing.small,
}
const $buttonContainer: ViewStyle = {
flexDirection: 'row',
alignSelf: 'center',
flexDirection: 'row',
alignSelf: 'center',
}
const $iconContainer: ImageStyle = {
padding: spacing.extraSmall,
alignSelf: "center",
marginRight: spacing.medium,
padding: spacing.extraSmall,
alignSelf: 'center',
marginRight: spacing.medium,
}
const $prText: TextStyle = {
overflow: 'hidden',
fontSize: 14,
overflow: 'hidden',
fontSize: 14,
}
const $card: ViewStyle = {
marginBottom: spacing.small,
marginBottom: spacing.small,
}
+124 -103
View File
@@ -1,89 +1,112 @@
import { formatDistance } from 'date-fns'
import { observer } from "mobx-react-lite"
import React from "react"
import { TextStyle, View, ViewStyle } from "react-native"
import { Icon, ListItem, Text } from "../../components"
import { Transaction, TransactionStatus, TransactionType } from "../../models/Transaction"
import { colors, spacing, typography, useThemeColor } from "../../theme"
import useIsInternetReachable from "../../utils/useIsInternetReachable"
import formatDistance from 'date-fns/formatDistance'
import {observer} from 'mobx-react-lite'
import React from 'react'
import {ScrollView, TextStyle, View, ViewStyle} from 'react-native'
import {Button, Icon, ListItem, Screen, Text} from '../../components'
import {
Transaction,
TransactionStatus,
TransactionType,
} from '../../models/Transaction'
import {colors, spacing, typography, useThemeColor} from '../../theme'
import useIsInternetReachable from '../../utils/useIsInternetReachable'
import {translate} from '../../i18n'
import { Currencies, MintUnit, MintUnitCurrencyPairs } from '../../services/wallet/currency'
import { CurrencyAmount } from '../Wallet/CurrencyAmount'
export interface TransactionListProps {
transaction: Transaction,
isFirst: boolean,
isTimeAgoVisible: boolean,
gotoTranDetail: any,
transaction: Transaction
isFirst: boolean
isTimeAgoVisible: boolean
gotoTranDetail: any
}
export const TransactionListItem = observer(function (props: TransactionListProps) {
const { transaction: tx, isTimeAgoVisible } = props
const txReceiveColor = colors.palette.success300
const txSendColor = useThemeColor('amount')
const txErrorColor = useThemeColor('textDim')
const txPendingColor = useThemeColor('textDim')
const isInternetReachable = useIsInternetReachable()
const getText = function(tx: Transaction) {
if(tx.noteToSelf) return tx.noteToSelf
// if(tx.memo) return tx.memo
switch(tx.type) {
case TransactionType.RECEIVE || TransactionType.RECEIVE_OFFLINE:
if(tx.sentFrom) {
if(!tx.memo || tx.memo.includes('Sent from Minibits')) {
return `From ${tx.sentFrom}`
}
} else {
return tx.memo ? tx.memo : 'You received'
}
case TransactionType.SEND:
return tx.memo ? tx.memo : tx.sentTo ? `Sent to ${tx.sentTo}` : 'You sent'
case TransactionType.TOPUP:
return tx.memo ? tx.memo : tx.sentFrom ? `Received from ${tx.sentFrom}` : 'You received'
case TransactionType.TRANSFER:
return tx.memo ? tx.memo : tx.sentTo ? `Paid to ${tx.sentTo}` : 'You paid'
default:
return 'Unknown transaction'
}
export const TransactionListItem = observer(function (
props: TransactionListProps,
) {
const {transaction: tx, isTimeAgoVisible} = props
const txReceiveColor = colors.palette.success300
const txSendColor = useThemeColor('amount')
const txErrorColor = useThemeColor('textDim')
const txPendingColor = useThemeColor('textDim')
const isInternetReachable = useIsInternetReachable()
const getText = function (tx: Transaction) {
if (tx.noteToSelf) return tx.noteToSelf
// if(tx.memo) return tx.memo
switch (tx.type) {
case TransactionType.RECEIVE || TransactionType.RECEIVE_OFFLINE:
if (tx.sentFrom) {
if (!tx.memo || tx.memo.includes('Sent from Minibits')) {
return translate('transactionCommon.from', {sender: tx.sentFrom})
}
} else {
return tx.memo ? tx.memo : translate('transactionCommon.youReceived')
}
case TransactionType.SEND:
return tx.memo
? tx.memo
: tx.sentTo
? translate('transactionCommon.sentTo', {receiver: tx.sentTo})
: translate('transactionCommon.youSent')
case TransactionType.TOPUP:
return tx.memo
? tx.memo
: tx.sentFrom
? translate('transactionCommon.receivedFrom', {sender: tx.sentFrom})
: translate('transactionCommon.youReceived')
case TransactionType.TRANSFER:
return tx.memo
? tx.memo
: tx.sentTo
? translate('transactionCommon.paidTo', {receiver: tx.sentTo})
: translate('transactionCommon.youPaid')
default:
return translate('transactionCommon.unknown')
}
const getSubText = function(tx: Transaction) {
let distance = formatDistance(tx.createdAt as Date, new Date(), {addSuffix: true})
let timeAgo = ''
if (isTimeAgoVisible) timeAgo = `${distance} · `
switch(tx.status) {
case TransactionStatus.COMPLETED:
return timeAgo + `Completed ${(tx.fee && tx.fee > 0) ? ' · Fee ' + tx.fee : ''}`
case TransactionStatus.DRAFT:
return timeAgo + `Draft`
case TransactionStatus.ERROR:
return timeAgo + `Error`
case TransactionStatus.PENDING:
return timeAgo + `Pending`
case TransactionStatus.PREPARED:
return timeAgo + `Prepared`
case TransactionStatus.PREPARED_OFFLINE:
if(isInternetReachable) {
return timeAgo + `Tap to redeem`
} else {
return timeAgo + `Redeem online`
}
case TransactionStatus.REVERTED:
return timeAgo + `Reverted`
case TransactionStatus.BLOCKED:
return timeAgo + `Blocked`
case TransactionStatus.EXPIRED:
return timeAgo + `Expired`
default:
return timeAgo
}
}
const getSubText = function (tx: Transaction) {
let distance = formatDistance(tx.createdAt as Date, new Date(), {
addSuffix: true,
})
let timeAgo = ''
if (isTimeAgoVisible) timeAgo = `${distance} · `
switch (tx.status) {
case TransactionStatus.COMPLETED:
return (
timeAgo +
translate('transactionCommon.status.completedFee', {
fee: (tx.fee && tx.fee > 0) ? ' · Fee ' + tx.fee : '',
})
)
case TransactionStatus.DRAFT:
return timeAgo + translate('transactionCommon.status.draft')
case TransactionStatus.ERROR:
return timeAgo + translate('transactionCommon.status.error')
case TransactionStatus.PENDING:
return timeAgo + translate('transactionCommon.status.pending')
case TransactionStatus.PREPARED:
return timeAgo + translate('transactionCommon.status.prepared')
case TransactionStatus.PREPARED_OFFLINE:
if (isInternetReachable) {
return timeAgo + translate('transactionCommon.tapToRedeem')
} else {
return timeAgo + translate('transactionCommon.redeemOnline')
}
case TransactionStatus.REVERTED:
return timeAgo + translate('transactionCommon.status.reverted')
case TransactionStatus.BLOCKED:
return timeAgo + translate('transactionCommon.status.blocked')
case TransactionStatus.EXPIRED:
return timeAgo + translate('transactionCommon.status.expired')
default:
return timeAgo
}
}
const getLeftIcon = function(tx: Transaction) {
@@ -183,29 +206,27 @@ export const TransactionListItem = observer(function (props: TransactionListProp
})
const $item: ViewStyle = {
marginHorizontal: spacing.micro,
}
const $item: ViewStyle = {
marginHorizontal: spacing.micro,
}
const $mintText: TextStyle = {
overflow: 'hidden',
fontSize: 14,
}
const $txContainer: ViewStyle = {
justifyContent: 'center',
alignSelf: 'center',
marginRight: spacing.extraSmall
}
const $txAmount: TextStyle = {
fontFamily: typography.primary?.medium
}
const $txIconContainer: ViewStyle = {
padding: spacing.extraSmall,
alignSelf: "center",
marginRight: spacing.medium,
}
const $mintText: TextStyle = {
overflow: 'hidden',
fontSize: 14,
}
const $txContainer: ViewStyle = {
justifyContent: 'center',
alignSelf: 'center',
marginRight: spacing.extraSmall,
}
const $txAmount: TextStyle = {
fontFamily: typography.primary?.medium,
}
const $txIconContainer: ViewStyle = {
padding: spacing.extraSmall,
alignSelf: 'center',
marginRight: spacing.medium,
}
+47 -40
View File
@@ -1,44 +1,51 @@
import React from "react"
import { TextStyle, View, ViewStyle } from "react-native"
import { Button, Icon, IconTypes, ListItem, Screen, Text } from "../../components"
import { colors, spacing, typography, useThemeColor } from "../../theme"
import { CurrencyCode } from "../../services/wallet/currency"
import React from 'react'
import {TextStyle, View, ViewStyle} from 'react-native'
import {Button, Icon, IconTypes, ListItem, Screen, Text} from '../../components'
import {colors, spacing, typography, useThemeColor} from '../../theme'
import {translate} from '../../i18n'
import { CurrencyCode } from '../../services/wallet/currency'
export const FeeBadge = function(props: {
currencyCode: CurrencyCode,
estimatedFee: number,
finalFee: number,
containerStyle?: ViewStyle,
textStyle?: TextStyle,
}
) {
const textColor = useThemeColor('header')
const bgColor = colors.palette.primary200
return (
<View
style={[{
alignSelf: 'center',
marginTop: spacing.tiny,
paddingHorizontal: spacing.tiny,
borderRadius: spacing.tiny,
backgroundColor: bgColor,
}, props.containerStyle || {}]}
>
<Text
text={props.finalFee ? `+ final fee ${props.finalFee} ${props.currencyCode}` : `+ fee up to ${props.estimatedFee} ${props.currencyCode}`}
style={[{
color: textColor,
fontSize: 10,
fontFamily: typography.primary?.light,
padding: 0,
lineHeight: 16,
}, props.textStyle || {}]}
/>
</View>
)
}
export const FeeBadge = function (props: {
currencyCode: CurrencyCode
estimatedFee: number
finalFee: number
containerStyle?: ViewStyle
textStyle?: TextStyle
}) {
const textColor = useThemeColor('header')
const bgColor = colors.palette.primary200
return (
<View
style={[
{
alignSelf: 'center',
marginTop: spacing.tiny,
paddingHorizontal: spacing.tiny,
borderRadius: spacing.tiny,
backgroundColor: bgColor,
},
props.containerStyle || {},
]}>
<Text
text={
props.finalFee
? translate('walletScreen.feeBadge.final', {fee: props.finalFee})
: translate('walletScreen.feeBadge.upto', {fee: props.estimatedFee})
}
style={[
{
color: textColor,
fontSize: 10,
fontFamily: typography.primary?.light,
padding: 0,
lineHeight: 16,
},
props.textStyle || {},
]}
/>
</View>
)
}
+7 -4
View File
@@ -23,6 +23,7 @@ import AppError, { Err } from '../utils/AppError'
import { MinibitsClient } from './minibitsService'
import { rootStoreInstance } from '../models'
import { WalletTask } from './walletService'
import { WalletTask } from './walletService'
export {
NostrEvent,
@@ -81,6 +82,7 @@ const reconnectToRelays = async function () {
// recreate subscriptions if all relays down
if(relaysStore.connectedCount === 0) {
WalletTask.handleSpentFromPending().catch(e => false)
WalletTask.handleSpentFromPending().catch(e => false)
}
const pool = getRelayPool()
@@ -202,6 +204,7 @@ const decryptNip04 = async function(
const publish = async function (
event: NostrUnsignedEvent,
relays: string[],
): Promise<NostrEvent | undefined> {
): Promise<NostrEvent | undefined> {
const keys: KeyPair = await getOrCreateKeyPair()
@@ -209,11 +212,11 @@ const publish = async function (
const signed = {...event} as NostrEvent
signed.created_at = Math.floor(Date.now() / 1000)
signed.id = getEventHash(event)
signed.sig = getSignature(event, keys.privateKey)
signed.id = getEventHash(signed)
signed.sig = getSignature(signed, keys.privateKey)
if(!validateEvent(event)) {
throw new AppError(Err.VALIDATION_ERROR, 'Event is invalid and could not be published', event)
if(!validateEvent(signed)) {
throw new AppError(Err.VALIDATION_ERROR, 'Event is invalid and could not be published', signed)
}
log.trace('Event to be published', signed, 'publish')