diff --git a/locales/en.json b/locales/en.json index 3f87e829a..6b45b97ca 100644 --- a/locales/en.json +++ b/locales/en.json @@ -973,6 +973,10 @@ "views.ActivityFilter.isFailed": "Failed payments", "views.ActivityFilter.standardInvoices": "Standard invoices", "views.ActivityFilter.ampInvoices": "AMP invoices", + "views.ActivityToCsv.title": "Download Activity", + "views.ActivityToCsv.csvDownloaded": "CSV file has been downloaded", + "views.ActivityToCsv.textInputPlaceholder": "File name (optional)", + "views.ActivityToCsv.downloadButton": "Download CSV", "views.Routing.RoutingEvent.sourceChannel": "Source Channel", "views.Routing.RoutingEvent.destinationChannel": "Destination Channel", "views.Olympians.title": "Olympians", diff --git a/models/Invoice.ts b/models/Invoice.ts index fda762a0e..ee039625c 100644 --- a/models/Invoice.ts +++ b/models/Invoice.ts @@ -2,10 +2,11 @@ import { observable, computed } from 'mobx'; import humanizeDuration from 'humanize-duration'; import BaseModel from './BaseModel'; -import Base64Utils from './../utils/Base64Utils'; -import DateTimeUtils from './../utils/DateTimeUtils'; -import Bolt11Utils from './../utils/Bolt11Utils'; -import { localeString } from './../utils/LocaleUtils'; +import Base64Utils from '../utils/Base64Utils'; +import DateTimeUtils from '../utils/DateTimeUtils'; +import Bolt11Utils from '../utils/Bolt11Utils'; +import { localeString } from '../utils/LocaleUtils'; +import stores from '../stores/Stores'; interface HopHint { fee_proportional_millionths: number; @@ -243,6 +244,10 @@ export default class Invoice extends BaseModel { ); } + @computed public get getCreationDate(): Date { + return DateTimeUtils.listDate(this.creation_date); + } + @computed public get formattedCreationDate(): string { return DateTimeUtils.listFormattedDate(this.creation_date); } @@ -387,4 +392,8 @@ export default class Invoice extends BaseModel { @computed public get getNoteKey(): string { return `note-${this.payment_hash || this.getRPreimage}`; } + + @computed public get getNote(): string { + return stores.notesStore.notes[this.getNoteKey] || ''; + } } diff --git a/models/Payment.ts b/models/Payment.ts index 487411864..acc786a68 100644 --- a/models/Payment.ts +++ b/models/Payment.ts @@ -9,6 +9,7 @@ import { localeString } from '../utils/LocaleUtils'; import Bolt11Utils from '../utils/Bolt11Utils'; import Base64Utils from '../utils/Base64Utils'; import { lnrpc } from '../proto/lightning'; +import stores from '../stores/Stores'; interface preimageBuffer { data: Array; @@ -312,4 +313,8 @@ export default class Payment extends BaseModel { @computed public get getNoteKey(): string { return `note-${this.paymentHash || this.getPreimage}`; } + + @computed public get getNote(): string { + return stores.notesStore.notes[this.getNoteKey] || ''; + } } diff --git a/models/Transaction.ts b/models/Transaction.ts index 9d0b3dcd0..ff052595b 100644 --- a/models/Transaction.ts +++ b/models/Transaction.ts @@ -2,8 +2,9 @@ import { computed } from 'mobx'; import BigNumber from 'bignumber.js'; import BaseModel from './BaseModel'; -import DateTimeUtils from './../utils/DateTimeUtils'; -import { localeString } from './../utils/LocaleUtils'; +import DateTimeUtils from '../utils/DateTimeUtils'; +import { localeString } from '../utils/LocaleUtils'; +import stores from '../stores/Stores'; interface OutputDetail { address: string; @@ -133,4 +134,8 @@ export default class Transaction extends BaseModel { @computed public get getNoteKey(): string { return `note-${this.tx}`; } + + @computed public get getNote(): string { + return stores.notesStore.notes[this.getNoteKey] || ''; + } } diff --git a/utils/ActivityFilterUtils.test.ts b/utils/ActivityFilterUtils.test.ts index 9749649a3..10d44cd44 100644 --- a/utils/ActivityFilterUtils.test.ts +++ b/utils/ActivityFilterUtils.test.ts @@ -2,6 +2,11 @@ jest.mock('dateformat', () => ({})); jest.mock('./LocaleUtils', () => ({ localeString: (s: string) => s })); +jest.mock('../stores/Stores', () => ({ + NotesStore: { + notes: [] + } +})); import Payment from '../models/Payment'; import Invoice from '../models/Invoice'; diff --git a/views/Activity/Activity.tsx b/views/Activity/Activity.tsx index e3e6355f5..9cf5a6b58 100644 --- a/views/Activity/Activity.tsx +++ b/views/Activity/Activity.tsx @@ -18,6 +18,7 @@ import Amount from '../../components/Amount'; import Header from '../../components/Header'; import LoadingIndicator from '../../components/LoadingIndicator'; import Screen from '../../components/Screen'; +import { Row } from '../../components/layout/Row'; import { localeString } from '../../utils/LocaleUtils'; import BackendUtils from '../../utils/BackendUtils'; @@ -32,6 +33,7 @@ import { SATS_PER_BTC } from '../../stores/UnitsStore'; import Filter from '../../assets/images/SVG/Filter On.svg'; import Invoice from '../../models/Invoice'; +import ActivityToCsv from './ActivityToCsv'; interface ActivityProps { navigation: StackNavigationProp; @@ -45,6 +47,7 @@ interface ActivityProps { interface ActivityState { selectedPaymentForOrder: any; + isCsvModalVisible: boolean; } @inject('ActivityStore', 'FiatStore', 'PosStore', 'SettingsStore', 'NotesStore') @@ -57,7 +60,8 @@ export default class Activity extends React.PureComponent< invoicesListener: any; state = { - selectedPaymentForOrder: null + selectedPaymentForOrder: null, + isCsvModalVisible: false }; async UNSAFE_componentWillMount() { @@ -143,7 +147,7 @@ export default class Activity extends React.PureComponent< SettingsStore, route } = this.props; - const { selectedPaymentForOrder } = this.state; + const { selectedPaymentForOrder, isCsvModalVisible } = this.state; const { loading, filteredActivity, getActivityAndFilter } = ActivityStore; @@ -233,23 +237,32 @@ export default class Activity extends React.PureComponent< } accessibilityLabel={localeString('views.ActivityFilter.title')} > - + ); - const getMatchingNote = (item: any) => { - const { NotesStore } = this.props; - const notes = NotesStore.notes; - - // Use the getNoteKey from the model - const noteKey = item.getNoteKey; - - if (noteKey && notes[noteKey]) { - return notes[noteKey]; - } - - return null; - }; + const DownloadButton = () => ( + + + this.setState({ + isCsvModalVisible: true + }) + } + accessibilityLabel={localeString( + 'views.ActivityToCsv.title' + )} + > + + + + ); return ( @@ -263,16 +276,30 @@ export default class Activity extends React.PureComponent< } }} rightComponent={ - order ? ( - selectedPaymentForOrder ? ( - - ) : undefined - ) : ( - - ) + !loading ? ( + + + {order ? ( + selectedPaymentForOrder ? ( + + ) : undefined + ) : ( + + )} + + ) : undefined } navigation={navigation} /> + + + this.setState({ isCsvModalVisible: false }) + } + isVisible={isCsvModalVisible} + /> + {loading ? ( @@ -281,7 +308,7 @@ export default class Activity extends React.PureComponent< { - const note = getMatchingNote(item); + const note = item.getNote; let displayName = item.model; let subTitle = item.model; diff --git a/views/Activity/ActivityFilter.tsx b/views/Activity/ActivityFilter.tsx index 166b9f8e7..bc015d585 100644 --- a/views/Activity/ActivityFilter.tsx +++ b/views/Activity/ActivityFilter.tsx @@ -264,7 +264,7 @@ export default class ActivityFilter extends React.Component< color={themeColor('text')} underlayColor="transparent" accessibilityLabel={localeString('general.clearChanges')} - size={30} + size={35} /> ); diff --git a/views/Activity/ActivityToCsv.tsx b/views/Activity/ActivityToCsv.tsx new file mode 100644 index 000000000..05d08dc7d --- /dev/null +++ b/views/Activity/ActivityToCsv.tsx @@ -0,0 +1,235 @@ +import React, { useState } from 'react'; +import { StyleSheet, View, Platform, Alert, Modal } from 'react-native'; +import RNFS from 'react-native-fs'; +import Button from '../../components/Button'; +import TextInput from '../../components/TextInput'; +import { localeString } from '../../utils/LocaleUtils'; +import { themeColor } from '../../utils/ThemeUtils'; +import Invoice from '../../models/Invoice'; +import Payment from '../../models/Payment'; +import Transaction from '../../models/Transaction'; +import LoadingIndicator from '../../components/LoadingIndicator'; + +interface ActivityProps { + filteredActivity: Array; + isVisible: boolean; + closeModal: () => void; +} + +const ActivityToCsv: React.FC = ({ + filteredActivity, + isVisible, + closeModal +}) => { + const [customFileName, setCustomFileName] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const closeAndClearInput = () => { + setCustomFileName(''); + closeModal(); + }; + + const getFormattedDateTime = () => { + const now = new Date(); + const year = now.getFullYear(); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const day = now.getDate().toString().padStart(2, '0'); + const hours = now.getHours().toString().padStart(2, '0'); + const minutes = now.getMinutes().toString().padStart(2, '0'); + const seconds = now.getSeconds().toString().padStart(2, '0'); + + return `${year}${month}${day}_${hours}${minutes}${seconds}`; + }; + + const convertActivityToCsv = async ( + data: Array, + keysToInclude: Array + ) => { + if (!data || data.length === 0) { + return ''; + } + + try { + const header = keysToInclude.map((field) => field.label).join(','); + const rows = data + ?.map((item: any) => + keysToInclude + .map((field) => `"${item[field.value]}"` || '') + .join(',') + ) + .join('\n'); + + return `${header}\n${rows}`; + } catch (err) { + console.error(err); + return ''; + } + }; + + const downloadCsv = async () => { + setIsLoading(true); + setTimeout(async () => { + const invoiceKeys = [ + { label: 'Amount Paid (sat)', value: 'getAmount' }, + { label: 'Note', value: 'getNote' }, + { label: 'Creation Date', value: 'getCreationDate' }, + { label: 'Expiry', value: 'formattedTimeUntilExpiry' } + ]; + + const paymentKeys = [ + { label: 'Destination', value: 'getDestination' }, + { label: 'Payment Hash', value: 'paymentHash' }, + { label: 'Amount Paid (sat)', value: 'getAmount' }, + { label: 'Note', value: 'getNote' }, + { label: 'Creation Date', value: 'getDate' } + ]; + + const transactionKeys = [ + { label: 'Transaction Hash', value: 'tx' }, + { label: 'Amount (sat)', value: 'getAmount' }, + { label: 'Total Fees (sat)', value: 'getFee' }, + { label: 'Note', value: 'getNote' }, + { label: 'Timestamp', value: 'getDate' } + ]; + + const invoiceCsv = await convertActivityToCsv( + filteredActivity.filter((item: any) => item instanceof Invoice), + invoiceKeys + ); + const paymentCsv = await convertActivityToCsv( + filteredActivity.filter((item: any) => item instanceof Payment), + paymentKeys + ); + const transactionCsv = await convertActivityToCsv( + filteredActivity.filter( + (item: any) => item instanceof Transaction + ), + transactionKeys + ); + + if (!invoiceCsv && !paymentCsv && !transactionCsv) { + setIsLoading(false); + return; + } + + try { + const dateTime = getFormattedDateTime(); + const baseFileName = customFileName || `zeus_${dateTime}`; + const invoiceFileName = `${baseFileName}_ln_invoices.csv`; + const paymentFileName = `${baseFileName}_ln_payments.csv`; + const transactionFileName = `${baseFileName}_onchain.csv`; + + const invoiceFilePath = + Platform.OS === 'android' + ? `${RNFS.DownloadDirectoryPath}/${invoiceFileName}` + : `${RNFS.DocumentDirectoryPath}/${invoiceFileName}`; + + const paymentFilePath = + Platform.OS === 'android' + ? `${RNFS.DownloadDirectoryPath}/${paymentFileName}` + : `${RNFS.DocumentDirectoryPath}/${paymentFileName}`; + + const transactionFilePath = + Platform.OS === 'android' + ? `${RNFS.DownloadDirectoryPath}/${transactionFileName}` + : `${RNFS.DocumentDirectoryPath}/${transactionFileName}`; + + if (invoiceCsv) { + console.log('invoiceFilePath', invoiceFilePath); + await RNFS.writeFile(invoiceFilePath, invoiceCsv, 'utf8'); + } + + if (paymentCsv) { + console.log('paymentFilePath', paymentFilePath); + await RNFS.writeFile(paymentFilePath, paymentCsv, 'utf8'); + } + + if (transactionCsv) { + console.log('transactionFilePath', transactionFilePath); + await RNFS.writeFile( + transactionFilePath, + transactionCsv, + 'utf8' + ); + } + + Alert.alert( + localeString('general.success'), + localeString('views.ActivityToCsv.csvDownloaded') + ); + closeModal(); + } catch (err) { + console.error('Failed to save CSV file:', err); + } finally { + setIsLoading(false); + } + }, 0); + }; + + return ( + + + + {isLoading ? ( + + ) : ( + <> + + +