Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Invoice: Improve expiry display #1534

Merged
merged 2 commits into from
Dec 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion components/KeyValue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ interface KeyValueProps {
sensitive?: boolean;
mempoolLink?: () => void;
disableCopy?: boolean;
SettingsStore: SettingsStore;
SettingsStore?: SettingsStore;
}

@inject('SettingsStore')
Expand Down
4 changes: 3 additions & 1 deletion locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,8 @@
"views.Invoice.receipt": "Receipt",
"views.Invoice.settleDate": "Settle Date",
"views.Invoice.creationDate": "Creation Date",
"views.Invoice.expiration": "Expiration",
"views.Invoice.originalExpiration": "Original Expiry",
"views.Invoice.expiration": "Time until Expiry",
"views.Invoice.private": "Private",
"views.Invoice.fallbackAddress": "Fallback Address",
"views.Invoice.cltvExpiry": "CLTV Expiry",
Expand Down Expand Up @@ -794,6 +795,7 @@
"views.Activity.youSent": "You sent",
"views.Activity.youReceived": "You received",
"views.Activity.requestedPayment": "Requested Payment",
"views.Activity.expiredRequested": "Expired Request",
"views.Activity.channelOperation": "Channel operation",
"views.ActivityFilter.title": "Filter Activity",
"views.ActivityFilter.set": "Set",
Expand Down
133 changes: 101 additions & 32 deletions models/Invoice.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { observable, computed } from 'mobx';
import BigNumber from 'bignumber.js';
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';

interface HopHint {
Expand Down Expand Up @@ -72,6 +73,9 @@ export default class Invoice extends BaseModel {
public millisatoshis?: string;
public pay_req?: string;

public formattedOriginalTimeUntilExpiry: string;
public formattedTimeUntilExpiry: string;

@computed public get model(): string {
return localeString('views.Invoice.title');
}
Expand Down Expand Up @@ -233,41 +237,14 @@ export default class Invoice extends BaseModel {
return DateTimeUtils.listFormattedDate(this.creation_date);
}

@computed public get expirationDate(): Date | string {
const expiry = this.expiry || this.expire_time;

// handle LNDHub
if (expiry && new BigNumber(expiry).gte(1600000000)) {
return DateTimeUtils.listFormattedDate(expiry);
}

if (expiry) {
if (expiry == '0') return localeString('models.Invoice.never');
return `${expiry} ${localeString('models.Invoice.seconds')}`;
}

return this.expires_at
? DateTimeUtils.listFormattedDate(this.expires_at)
: localeString('models.Invoice.never');
}

@computed public get isExpired(): boolean {
const expiry = this.expiry || this.expire_time;
const getExpiryTimestamp = this.getExpiryUnixTimestamp();

if (expiry && new BigNumber(expiry).gte(1600000000)) {
return (
new Date().getTime() > DateTimeUtils.listDate(expiry).getTime()
);
if (getExpiryTimestamp == null) {
return false;
}

if (expiry) {
return (
new Date().getTime() / 1000 >
Number(this.creation_date) + Number(expiry)
);
}

return false;
return getExpiryTimestamp * 1000 <= Date.now();
}

@computed public get getKeysendMessage(): string {
Expand All @@ -293,4 +270,96 @@ export default class Invoice extends BaseModel {
if (this.getMemo?.startsWith('ZEUS PAY')) return true;
return false;
}

@computed public get originalTimeUntilExpiryInSeconds():
| number
| undefined {
const decodedPaymentRequest = Bolt11Utils.decode(
this.getPaymentRequest
);
if (this.expires_at != null) {
// expiry is missing in payment request in Core Lightning
return this.expires_at - decodedPaymentRequest.timestamp;
}
return decodedPaymentRequest.expiry;
}

public determineFormattedOriginalTimeUntilExpiry(
locale: string | undefined
): void {
const originalTimeUntilExpiryInSeconds =
this.originalTimeUntilExpiryInSeconds;

if (originalTimeUntilExpiryInSeconds == null) {
return localeString('models.Invoice.never');
}

const originalTimeUntilExpiryInMs =
originalTimeUntilExpiryInSeconds * 1000;

this.formattedOriginalTimeUntilExpiry =
this.formatHumanReadableDuration(
originalTimeUntilExpiryInMs,
locale
);
}

public determineFormattedRemainingTimeUntilExpiry(
locale: string | undefined
): void {
const millisecondsUntilExpiry =
this.getRemainingMillisecondsUntilExpiry();

if (millisecondsUntilExpiry == null) {
this.formattedTimeUntilExpiry = localeString(
'models.Invoice.never'
);
return;
}

this.formattedTimeUntilExpiry =
millisecondsUntilExpiry <= 0
? localeString('views.Activity.expired')
: this.formatHumanReadableDuration(
millisecondsUntilExpiry,
locale
);
}

private getRemainingMillisecondsUntilExpiry(): number | undefined {
const expiryTimestamp = this.getExpiryUnixTimestamp();

return expiryTimestamp != null
? expiryTimestamp * 1000 - Date.now()
: undefined;
}

private getExpiryUnixTimestamp(): number | undefined {
const originalTimeUntilExpiryInSeconds =
this.originalTimeUntilExpiryInSeconds;

if (originalTimeUntilExpiryInSeconds == null) {
return undefined;
}

const paymentRequestTimestamp = Bolt11Utils.decode(
this.getPaymentRequest
).timestamp;

return paymentRequestTimestamp + originalTimeUntilExpiryInSeconds;
}

private formatHumanReadableDuration(
durationInMs: number,
locale: string | undefined
) {
return humanizeDuration(durationInMs, {
language: locale === 'zh' ? 'zh_CN' : locale,
fallbacks: ['en'],
round: true,
largest: 2
})
.replace(/(\d+) /g, '$1 ')
.replace(/ (\d+)/g, ' $1');
}
}
49 changes: 49 additions & 0 deletions models/Payment.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { computed } from 'mobx';
import bolt11 from 'bolt11';
import BigNumber from 'bignumber.js';
import humanizeDuration from 'humanize-duration';

import BaseModel from './BaseModel';
import DateTimeUtils from '../utils/DateTimeUtils';
import { localeString } from '../utils/LocaleUtils';
import Bolt11Utils from '../utils/Bolt11Utils';
import { lnrpc } from '../proto/lightning';

export default class Payment extends BaseModel {
Expand Down Expand Up @@ -167,4 +169,51 @@ export default class Payment extends BaseModel {

return enhancedPath;
}

@computed public get originalTimeUntilExpiryInSeconds():
| number
| undefined {
const decodedPaymentRequest =
this.payment_request != null
? Bolt11Utils.decode(this.payment_request)
: this.bolt
? Bolt11Utils.decode(this.bolt)
: this.bolt11
? Bolt11Utils.decode(this.bolt11)
: null;
return decodedPaymentRequest?.expiry;
}

public getFormattedOriginalTimeUntilExpiry(
locale: string | undefined
): string {
const originalTimeUntilExpiryInSeconds =
this.originalTimeUntilExpiryInSeconds;

if (originalTimeUntilExpiryInSeconds == null) {
return localeString('models.Invoice.never');
}

const originalTimeUntilExpiryInMs =
originalTimeUntilExpiryInSeconds * 1000;

return this.formatHumanReadableDuration(
originalTimeUntilExpiryInMs,
locale
);
}

private formatHumanReadableDuration(
durationInMs: number,
locale: string | undefined
) {
return humanizeDuration(durationInMs, {
language: locale === 'zh' ? 'zh_CN' : locale,
fallbacks: ['en'],
round: true,
largest: 2
})
.replace(/(\d+) /g, '$1 ')
.replace(/ (\d+)/g, ' $1');
}
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@
"@remobile/react-native-qrcode-local-image": "github:BlueWallet/react-native-qrcode-local-image#31b0113",
"@tradle/react-native-http": "2.0.1",
"@types/dateformat": "5.0.0",
"@types/humanize-duration": "3.27.1",
"@types/react-native-snap-carousel": "3.8.5",
"assert": "1.5.0",
"base-x": "4.0.0",
"bc-ur": "0.1.6",
"bech32": "2.0.0",
"bignumber.js": "9.0.2",
"bip39": "3.1.0",
"bitcoinjs-lib": "3.3.2",
Expand All @@ -65,6 +67,7 @@
"fast-sha256": "1.3.0",
"hash.js": "1.1.7",
"https-browserify": "0.0.1",
"humanize-duration": "3.28.0",
"identicon.js": "2.3.3",
"inherits": "2.0.4",
"js-lnurl": "0.5.1",
Expand Down
24 changes: 16 additions & 8 deletions stores/ActivityStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export default class ActivityStore {
};

getSortedActivity = () => {
const activity: any = [];
const activity: any[] = [];
const payments = this.paymentsStore.payments;
const transactions = this.transactionsStore.transactions;
const invoices = this.invoicesStore.invoices;
Expand Down Expand Up @@ -169,18 +169,18 @@ export default class ActivityStore {
};

@action
public updateInvoices = async () => {
public updateInvoices = async (locale: string | undefined) => {
await this.invoicesStore.getInvoices();
this.activity = this.getSortedActivity();
await this.setFilters(this.filters);
await this.setFilters(this.filters, locale);
};

@action
public updateTransactions = async () => {
public updateTransactions = async (locale: string | undefined) => {
if (BackendUtils.supportsOnchainSends())
await this.transactionsStore.getTransactions();
this.activity = this.getSortedActivity();
await this.setFilters(this.filters);
await this.setFilters(this.filters, locale);
};

@action
Expand All @@ -207,20 +207,28 @@ export default class ActivityStore {
}

@action
public setFilters = async (filters: Filter) => {
public setFilters = async (filters: Filter, locale: string | undefined) => {
this.loading = true;
this.filters = filters;
this.filteredActivity = ActivityFilterUtils.filterActivities(
this.activity,
filters
);
this.filteredActivity.forEach((activity) => {
if (activity instanceof Invoice) {
activity.determineFormattedRemainingTimeUntilExpiry(locale);
}
});
await EncryptedStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
this.loading = false;
};

@action
public getActivityAndFilter = async (filters: Filter = this.filters) => {
public getActivityAndFilter = async (
locale: string | undefined,
filters: Filter = this.filters
) => {
await this.getActivity();
await this.setFilters(filters);
await this.setFilters(filters, locale);
};
}
12 changes: 12 additions & 0 deletions utils/Base64Utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,16 @@ describe('Base64Utils', () => {
);
});
});

describe('bytesToUtf8', () => {
it('converts a byte array to utf-8', () => {
const input = Uint8Array.from([
84, 104, 105, 115, 32, 105, 115, 32, 97, 32, 116, 101, 115, 116
]);

const utf8 = Base64Utils.bytesToUtf8(input);

expect(utf8).toBe('This is a test');
});
});
});
2 changes: 2 additions & 0 deletions utils/Base64Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ class Base64Utils {
''
);

bytesToUtf8 = (input: Uint8Array) => Buffer.from(input).toString('utf-8');

utf8ToHex = (input: string) => Buffer.from(input, 'utf8').toString('hex');

base64UrlToHex = (input: string) =>
Expand Down
23 changes: 23 additions & 0 deletions utils/Bolt11Utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Bolt11Utils from './Bolt11Utils';

describe('decode', () => {
it('correctly decodes a valid payment request', () => {
const paymentRequest =
'lnbcrt1230n1pj429x7pp57t97q4awqj3f529snr0pa6senk83sq5pp760qf5a4jzvd7xgwcksdqqcqzzsxqrrsssp57eqtv7vxr46arupna3w4ct0lkf2mqmz9wt044cwkks0rwlnhfr5s9qyyssqragwpwav7nfwv2xyuuamxxj4pnnpzv2hlw7j473repd3sq7st698ta9kmzmygt0w7tmncl56a6mnma0w7e5dlpqd0wy6x3v35rssldspjhh8p0';

const decoded = Bolt11Utils.decode(paymentRequest);

expect(decoded.expiry).toBe(3600);
expect(decoded.timestamp).toBe(1700074718);
expect(decoded.paymentRequest).toBe(paymentRequest);
});

it('throws an error if an invalid payment request is given', () => {
const paymentRequest =
'bcrt1230n1pj429x7pp57t97q4awqj3f529snr0pa6senk83sq5pp760qf5a4jzvd7xgwcksdqqcqzzsxqrrsssp57eqtv7vxr46arupna3w4ct0lkf2mqmz9wt044cwkks0rwlnhfr5s9qyyssqragwpwav7nfwv2xyuuamxxj4pnnpzv2hlw7j473repd3sq7st698ta9kmzmygt0w7tmncl56a6mnma0w7e5dlpqd0wy6x3v35rssldspjhh8p0';

const action = () => Bolt11Utils.decode(paymentRequest);

expect(action).toThrowError('Not a proper lightning payment request');
});
});