From d58945c830a33910dd93bc159c1ffe5d490df846 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 16 Jun 2021 17:17:12 -0300 Subject: split wallet/popup components. created hooks, components, context folder --- .../src/popup/popup.stories.tsx | 197 +++++ .../taler-wallet-webextension/src/popup/popup.tsx | 963 +++++++++++++++++++++ 2 files changed, 1160 insertions(+) create mode 100644 packages/taler-wallet-webextension/src/popup/popup.stories.tsx create mode 100644 packages/taler-wallet-webextension/src/popup/popup.tsx (limited to 'packages/taler-wallet-webextension/src/popup') diff --git a/packages/taler-wallet-webextension/src/popup/popup.stories.tsx b/packages/taler-wallet-webextension/src/popup/popup.stories.tsx new file mode 100644 index 000000000..0cb51a336 --- /dev/null +++ b/packages/taler-wallet-webextension/src/popup/popup.stories.tsx @@ -0,0 +1,197 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { + PaymentStatus, + TransactionCommon, TransactionDeposit, TransactionPayment, + TransactionRefresh, TransactionRefund, TransactionTip, TransactionType, + TransactionWithdrawal, + WithdrawalType +} from '@gnu-taler/taler-util'; +import { WalletTransactionView as Component } from './popup'; + +export default { + title: 'popup/transaction details', + component: Component, + decorators: [ + (Story: any) =>
+ + + +
+ +
+
+ ], +}; + +const commonTransaction = { + amountRaw: 'USD:10', + amountEffective: 'USD:9', + pending: false, + timestamp: { + t_ms: new Date().getTime() + }, + transactionId: '12', +} as TransactionCommon + +const exampleData = { + withdraw: { + ...commonTransaction, + type: TransactionType.Withdrawal, + exchangeBaseUrl: 'http://exchange.taler', + withdrawalDetails: { + confirmed: false, + exchangePaytoUris: ['payto://x-taler-bank/bank/account'], + type: WithdrawalType.ManualTransfer, + } + } as TransactionWithdrawal, + payment: { + ...commonTransaction, + amountEffective: 'USD:11', + type: TransactionType.Payment, + info: { + contractTermsHash: 'ASDZXCASD', + merchant: { + name: 'the merchant', + }, + orderId: '#12345', + products: [], + summary: 'the summary', + fulfillmentMessage: '', + }, + proposalId: '1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0', + status: PaymentStatus.Accepted, + } as TransactionPayment, + deposit: { + ...commonTransaction, + type: TransactionType.Deposit, + depositGroupId: '#groupId', + targetPaytoUri: 'payto://x-taler-bank/bank/account', + } as TransactionDeposit, + refresh: { + ...commonTransaction, + type: TransactionType.Refresh, + exchangeBaseUrl: 'http://exchange.taler', + } as TransactionRefresh, + tip: { + ...commonTransaction, + type: TransactionType.Tip, + merchantBaseUrl: 'http://merchant.taler', + } as TransactionTip, + refund: { + ...commonTransaction, + type: TransactionType.Refund, + refundedTransactionId: 'payment:1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0', + info: { + contractTermsHash: 'ASDZXCASD', + merchant: { + name: 'the merchant', + }, + orderId: '#12345', + products: [], + summary: 'the summary', + fulfillmentMessage: '', + }, + } as TransactionRefund, +} + +function dynamic(props: any) { + const r = (args: any) => + r.args = props + return r +} + +export const NotYetLoaded = dynamic({}); + +export const Withdraw = dynamic({ + transaction: exampleData.withdraw +}); + +export const WithdrawPending = dynamic({ + transaction: { ...exampleData.withdraw, pending: true }, +}); + + +export const Payment = dynamic({ + transaction: exampleData.payment +}); + +export const PaymentPending = dynamic({ + transaction: { ...exampleData.payment, pending: true }, +}); + +export const PaymentWithProducts = dynamic({ + transaction: { + ...exampleData.payment, + info: { + ...exampleData.payment.info, + products: [{ + description: 't-shirt', + }, { + description: 'beer', + }] + } + } as TransactionPayment, +}); + + +export const Deposit = dynamic({ + transaction: exampleData.deposit +}); + +export const DepositPending = dynamic({ + transaction: { ...exampleData.deposit, pending: true } +}); + +export const Refresh = dynamic({ + transaction: exampleData.refresh +}); + +export const Tip = dynamic({ + transaction: exampleData.tip +}); + +export const TipPending = dynamic({ + transaction: { ...exampleData.tip, pending: true } +}); + +export const Refund = dynamic({ + transaction: exampleData.refund +}); + +export const RefundPending = dynamic({ + transaction: { ...exampleData.refund, pending: true } +}); + +export const RefundWithProducts = dynamic({ + transaction: { + ...exampleData.refund, + info: { + ...exampleData.refund.info, + products: [{ + description: 't-shirt', + }, { + description: 'beer', + }] + } + } as TransactionRefund, +}); diff --git a/packages/taler-wallet-webextension/src/popup/popup.tsx b/packages/taler-wallet-webextension/src/popup/popup.tsx new file mode 100644 index 000000000..0f76d7728 --- /dev/null +++ b/packages/taler-wallet-webextension/src/popup/popup.tsx @@ -0,0 +1,963 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see + */ + +/** + * Popup shown to the user when they click + * the Taler browser action button. + * + * @author Florian Dold + */ + +/** + * Imports. + */ +import { + AmountJson, + Amounts, + BalancesResponse, + Balance, + classifyTalerUri, + TalerUriType, + TransactionsResponse, + Transaction, + TransactionType, + AmountString, + Timestamp, + amountFractionalBase, + i18n, +} from "@gnu-taler/taler-util"; +import { format } from "date-fns"; +import { Component, ComponentChildren, Fragment, JSX } from "preact"; +import { route } from 'preact-router'; +import { useEffect, useState } from "preact/hooks"; +import { Diagnostics } from "../components/Diagnostics"; +import { PermissionsCheckbox } from "../components/PermissionsCheckbox"; +import { useExtendedPermissions } from "../hooks/useExtendedPermissions"; +import { PageLink, renderAmount } from "../renderHtml"; +import * as wxApi from "../wxApi"; + +export enum Pages { + balance = '/balance', + settings = '/settings', + debug = '/debug', + history = '/history', + transaction = '/transaction/:tid', +} + +interface TabProps { + target: string; + current?: string; + children?: ComponentChildren; +} + +function Tab(props: TabProps): JSX.Element { + let cssClass = ""; + if (props.current === props.target) { + cssClass = "active"; + } + return ( + + {props.children} + + ); +} + +export function WalletNavBar({ current }: { current?: string }) { + return ( + + ); +} + +/** + * Render an amount as a large number with a small currency symbol. + */ +function bigAmount(amount: AmountJson): JSX.Element { + const v = amount.value + amount.fraction / amountFractionalBase; + return ( + + {v}{" "} + {amount.currency} + + ); +} + +function EmptyBalanceView(): JSX.Element { + return ( +

+ You have no balance to show. Need some{" "} + help getting started? +

+ ); +} + +export class WalletBalanceView extends Component { + private balance?: BalancesResponse; + private gotError = false; + private canceler: (() => void) | undefined = undefined; + private unmount = false; + private updateBalanceRunning = false; + + componentWillMount(): void { + this.canceler = wxApi.onUpdateNotification(() => this.updateBalance()); + this.updateBalance(); + } + + componentWillUnmount(): void { + console.log("component WalletBalanceView will unmount"); + if (this.canceler) { + this.canceler(); + } + this.unmount = true; + } + + async updateBalance(): Promise { + if (this.updateBalanceRunning) { + return; + } + this.updateBalanceRunning = true; + let balance: BalancesResponse; + try { + balance = await wxApi.getBalance(); + } catch (e) { + if (this.unmount) { + return; + } + this.gotError = true; + console.error("could not retrieve balances", e); + this.setState({}); + return; + } finally { + this.updateBalanceRunning = false; + } + if (this.unmount) { + return; + } + this.gotError = false; + console.log("got balance", balance); + this.balance = balance; + this.setState({}); + } + + formatPending(entry: Balance): JSX.Element { + let incoming: JSX.Element | undefined; + let payment: JSX.Element | undefined; + + const available = Amounts.parseOrThrow(entry.available); + const pendingIncoming = Amounts.parseOrThrow(entry.pendingIncoming); + const pendingOutgoing = Amounts.parseOrThrow(entry.pendingOutgoing); + + console.log( + "available: ", + entry.pendingIncoming ? renderAmount(entry.available) : null, + ); + console.log( + "incoming: ", + entry.pendingIncoming ? renderAmount(entry.pendingIncoming) : null, + ); + + if (!Amounts.isZero(pendingIncoming)) { + incoming = ( + + + {"+"} + {renderAmount(entry.pendingIncoming)} + {" "} + incoming + + ); + } + + const l = [incoming, payment].filter((x) => x !== undefined); + if (l.length === 0) { + return ; + } + + if (l.length === 1) { + return ({l}); + } + return ( + + ({l[0]}, {l[1]}) + + ); + } + + render(): JSX.Element { + const wallet = this.balance; + if (this.gotError) { + return ( +
+

{i18n.str`Error: could not retrieve balance information.`}

+

+ Click here for help and + diagnostics. +

+
+ ); + } + if (!wallet) { + return ; + } + console.log(wallet); + const listing = wallet.balances.map((entry) => { + const av = Amounts.parseOrThrow(entry.available); + return ( +

+ {bigAmount(av)} {this.formatPending(entry)} +

+ ); + }); + return listing.length > 0 ? ( +
{listing}
+ ) : ( + + ); + } +} + +interface TransactionAmountProps { + debitCreditIndicator: "debit" | "credit" | "unknown"; + amount: AmountString | "unknown"; + pending: boolean; +} + +function TransactionAmount(props: TransactionAmountProps): JSX.Element { + const [currency, amount] = props.amount.split(":"); + let sign: string; + switch (props.debitCreditIndicator) { + case "credit": + sign = "+"; + break; + case "debit": + sign = "-"; + break; + case "unknown": + sign = ""; + } + const style: JSX.AllCSSProperties = { + marginLeft: "auto", + display: "flex", + flexDirection: "column", + alignItems: "center", + alignSelf: "center" + }; + if (props.pending) { + style.color = "gray"; + } + return ( +
+
+ {sign} + {amount} +
+
{currency}
+
+ ); +} + +interface TransactionLayoutProps { + debitCreditIndicator: "debit" | "credit" | "unknown"; + amount: AmountString | "unknown"; + timestamp: Timestamp; + title: string; + id: string; + subtitle: string; + iconPath: string; + pending: boolean; +} + +function TransactionLayout(props: TransactionLayoutProps): JSX.Element { + const date = new Date(props.timestamp.t_ms); + const dateStr = date.toLocaleString([], { + dateStyle: "medium", + timeStyle: "short", + } as any); + return ( +
+ +
+
{dateStr}
+
+ {props.title} + {props.pending ? ( + (Pending) + ) : null} +
+ +
{props.subtitle}
+
+ +
+ ); +} + +function TransactionItem(props: { tx: Transaction }): JSX.Element { + const tx = props.tx; + switch (tx.type) { + case TransactionType.Withdrawal: + return ( + + ); + case TransactionType.Payment: + return ( + + ); + case TransactionType.Refund: + return ( + + ); + case TransactionType.Tip: + return ( + + ); + case TransactionType.Refresh: + return ( + + ); + case TransactionType.Deposit: + return ( + + ); + } +} + +export function WalletHistory(props: any): JSX.Element { + const [transactions, setTransactions] = useState< + TransactionsResponse | undefined + >(undefined); + + useEffect(() => { + const fetchData = async (): Promise => { + const res = await wxApi.getTransactions(); + setTransactions(res); + }; + fetchData(); + }, []); + + if (!transactions) { + return
Loading ...
; + } + + const txs = [...transactions.transactions].reverse(); + + return ( +
+ {txs.map((tx, i) => ( + + ))} +
+ ); +} + +interface WalletTransactionProps { + transaction?: Transaction, + onDelete: () => void, + onBack: () => void, +} + +export function WalletTransactionView({ transaction, onDelete, onBack }: WalletTransactionProps) { + if (!transaction) { + return
Loading ...
; + } + + function Footer() { + return
+ +
+ + +
+ +
+ } + + function Pending() { + if (!transaction?.pending) return null + return (pending...) + } + + if (transaction.type === TransactionType.Withdrawal) { + return ( +
+
+

Withdrawal

+

+ From {transaction.exchangeBaseUrl} +

+ + + + + + + + + + + + + + + + + +
Amount subtracted{transaction.amountRaw}
Amount received{transaction.amountEffective}
Exchange fee{Amounts.stringify( + Amounts.sub( + Amounts.parseOrThrow(transaction.amountRaw), + Amounts.parseOrThrow(transaction.amountEffective), + ).amount + )}
When{transaction.timestamp.t_ms === "never" ? "never" : format(transaction.timestamp.t_ms, 'dd/MM/yyyy HH:mm:ss')}
+
+
+
+ ); + } + + if (transaction.type === TransactionType.Payment) { + return ( +
+
+

Payment ({transaction.proposalId.substring(0, 10)}...)

+

+ To {transaction.info.merchant.name} +

+ + + + + + + + + + {transaction.info.products && transaction.info.products.length > 0 && + + + + + } + + + + + + + + + + + + + + + + +
Order id{transaction.info.orderId}
Summary{transaction.info.summary}
Products
    + {transaction.info.products.map(p => +
  1. {p.description}
  2. + )}
Order amount{transaction.amountRaw}
Order amount and fees{transaction.amountEffective}
Exchange fee{Amounts.stringify( + Amounts.sub( + Amounts.parseOrThrow(transaction.amountEffective), + Amounts.parseOrThrow(transaction.amountRaw), + ).amount + )}
When{transaction.timestamp.t_ms === "never" ? "never" : format(transaction.timestamp.t_ms, 'dd/MM/yyyy HH:mm:ss')}
+
+
+
+ ); + } + + if (transaction.type === TransactionType.Deposit) { + return ( +
+
+

Deposit ({transaction.depositGroupId})

+

+ To {transaction.targetPaytoUri} +

+ + + + + + + + + + + + + + + + + +
Amount deposit{transaction.amountRaw}
Amount deposit and fees{transaction.amountEffective}
Exchange fee{Amounts.stringify( + Amounts.sub( + Amounts.parseOrThrow(transaction.amountEffective), + Amounts.parseOrThrow(transaction.amountRaw), + ).amount + )}
When{transaction.timestamp.t_ms === "never" ? "never" : format(transaction.timestamp.t_ms, 'dd/MM/yyyy HH:mm:ss')}
+
+
+
+ ); + } + + if (transaction.type === TransactionType.Refresh) { + return ( +
+
+

Refresh

+

+ From {transaction.exchangeBaseUrl} +

+ + + + + + + + + + + + + +
Amount refreshed{transaction.amountRaw}
Fees{transaction.amountEffective}
When{transaction.timestamp.t_ms === "never" ? "never" : format(transaction.timestamp.t_ms, 'dd/MM/yyyy HH:mm:ss')}
+
+
+
+ ); + } + + if (transaction.type === TransactionType.Tip) { + return ( +
+
+

Tip

+

+ From {transaction.merchantBaseUrl} +

+ + + + + + + + + + + + + + + + + +
Amount deduce{transaction.amountRaw}
Amount received{transaction.amountEffective}
Exchange fee{Amounts.stringify( + Amounts.sub( + Amounts.parseOrThrow(transaction.amountRaw), + Amounts.parseOrThrow(transaction.amountEffective), + ).amount + )}
When{transaction.timestamp.t_ms === "never" ? "never" : format(transaction.timestamp.t_ms, 'dd/MM/yyyy HH:mm:ss')}
+
+
+
+ ); + } + + const TRANSACTION_FROM_REFUND = /[a-z]*:([\w]{10}).*/ + if (transaction.type === TransactionType.Refund) { + return ( +
+
+

Refund ({TRANSACTION_FROM_REFUND.exec(transaction.refundedTransactionId)![1]}...)

+

+ From {transaction.info.merchant.name} +

+ + + + + + + + + + {transaction.info.products && transaction.info.products.length > 0 && + + + + + } + + + + + + + + + + + + + + + + +
Order id{transaction.info.orderId}
Summary{transaction.info.summary}
Products
    + {transaction.info.products.map(p => +
  1. {p.description}
  2. + )}
Amount deduce{transaction.amountRaw}
Amount received{transaction.amountEffective}
Exchange fee{Amounts.stringify( + Amounts.sub( + Amounts.parseOrThrow(transaction.amountRaw), + Amounts.parseOrThrow(transaction.amountEffective), + ).amount + )}
When{transaction.timestamp.t_ms === "never" ? "never" : format(transaction.timestamp.t_ms, 'dd/MM/yyyy HH:mm:ss')}
+
+
+
+ ); + } + + + return
+} + +export function WalletTransaction({ tid }: { tid: string }): JSX.Element { + const [transaction, setTransaction] = useState< + Transaction | undefined + >(undefined); + + useEffect(() => { + const fetchData = async (): Promise => { + const res = await wxApi.getTransactions(); + const ts = res.transactions.filter(t => t.transactionId === tid) + if (ts.length === 1) { + setTransaction(ts[0]); + } else { + route(Pages.history) + } + }; + fetchData(); + }, []); + + return wxApi.deleteTransaction(tid).then(_ => history.go(-1))} + onBack={() => { history.go(-1) }} + /> +} + +export function WalletSettings() { + const [permissionsEnabled, togglePermissions] = useExtendedPermissions() + return ( +
+

Permissions

+ + {/* +

Developer mode

+ + */} +
+ ); +} + + +export function DebugCheckbox({ enabled, onToggle }: { enabled: boolean, onToggle: () => void }): JSX.Element { + return ( +
+ + + + (Enabling this option below will make using the wallet faster, but + requires more permissions from your browser.) + +
+ ); +} + +function reload(): void { + try { + chrome.runtime.reload(); + window.close(); + } catch (e) { + // Functionality missing in firefox, ignore! + } +} + +async function confirmReset(): Promise { + if ( + confirm( + "Do you want to IRREVOCABLY DESTROY everything inside your" + + " wallet and LOSE ALL YOUR COINS?", + ) + ) { + await wxApi.resetDb(); + window.close(); + } +} + +export function WalletDebug(props: any): JSX.Element { + return ( +
+

Debug tools:

+ +
+ + + +
+ ); +} + +function openExtensionPage(page: string) { + return () => { + chrome.tabs.create({ + url: chrome.extension.getURL(page), + }); + }; +} + +// function openTab(page: string) { +// return (evt: React.SyntheticEvent) => { +// evt.preventDefault(); +// chrome.tabs.create({ +// url: page, +// }); +// }; +// } + +function makeExtensionUrlWithParams( + url: string, + params?: { [name: string]: string | undefined }, +): string { + const innerUrl = new URL(chrome.extension.getURL("/" + url)); + if (params) { + for (const key in params) { + const p = params[key]; + if (p) { + innerUrl.searchParams.set(key, p); + } + } + } + return innerUrl.href; +} + +export function actionForTalerUri(talerUri: string): string | undefined { + const uriType = classifyTalerUri(talerUri); + switch (uriType) { + case TalerUriType.TalerWithdraw: + return makeExtensionUrlWithParams("static/wallet.html#/withdraw", { + talerWithdrawUri: talerUri, + }); + case TalerUriType.TalerPay: + return makeExtensionUrlWithParams("static/wallet.html#/pay", { + talerPayUri: talerUri, + }); + case TalerUriType.TalerTip: + return makeExtensionUrlWithParams("static/wallet.html#/tip", { + talerTipUri: talerUri, + }); + case TalerUriType.TalerRefund: + return makeExtensionUrlWithParams("static/wallet.html#/refund", { + talerRefundUri: talerUri, + }); + case TalerUriType.TalerNotifyReserve: + // FIXME: implement + break; + default: + console.warn( + "Response with HTTP 402 has Taler header, but header value is not a taler:// URI.", + ); + break; + } + return undefined; +} + +export async function findTalerUriInActiveTab(): Promise { + return new Promise((resolve, reject) => { + chrome.tabs.executeScript( + { + code: ` + (() => { + let x = document.querySelector("a[href^='taler://'") || document.querySelector("a[href^='taler+http://'"); + return x ? x.href.toString() : null; + })(); + `, + allFrames: false, + }, + (result) => { + if (chrome.runtime.lastError) { + console.error(chrome.runtime.lastError); + resolve(undefined); + return; + } + console.log("got result", result); + resolve(result[0]); + }, + ); + }); +} + +// export function WalletPopup(): JSX.Element { +// const [talerActionUrl, setTalerActionUrl] = useState( +// undefined, +// ); +// const [dismissed, setDismissed] = useState(false); +// useEffect(() => { +// async function check(): Promise { +// const talerUri = await findTalerUriInActiveTab(); +// if (talerUri) { +// const actionUrl = actionForTalerUri(talerUri); +// setTalerActionUrl(actionUrl); +// } +// } +// check(); +// }, []); +// if (talerActionUrl && !dismissed) { +// return ( +//
+//

Taler Action

+//

This page has a Taler action.

+//

+// +//

+//

+// +//

+//
+// ); +// } +// return ( +//
+// {({ path }: any) => } +//
+// +// +// +// +// +// +// +//
+//
+// ); +// } + -- cgit v1.2.3