diff options
Diffstat (limited to 'src/webex/pages')
-rw-r--r-- | src/webex/pages/add-auditor.html | 34 | ||||
-rw-r--r-- | src/webex/pages/add-auditor.tsx | 126 | ||||
-rw-r--r-- | src/webex/pages/auditors.html | 36 | ||||
-rw-r--r-- | src/webex/pages/auditors.tsx | 147 | ||||
-rw-r--r-- | src/webex/pages/confirm-contract.html | 69 | ||||
-rw-r--r-- | src/webex/pages/confirm-contract.tsx | 242 | ||||
-rw-r--r-- | src/webex/pages/confirm-create-reserve.html | 52 | ||||
-rw-r--r-- | src/webex/pages/confirm-create-reserve.tsx | 641 | ||||
-rw-r--r-- | src/webex/pages/error.html | 18 | ||||
-rw-r--r-- | src/webex/pages/error.tsx | 63 | ||||
-rw-r--r-- | src/webex/pages/help/empty-wallet.html | 30 | ||||
-rw-r--r-- | src/webex/pages/logs.html | 27 | ||||
-rw-r--r-- | src/webex/pages/logs.tsx | 83 | ||||
-rw-r--r-- | src/webex/pages/payback.html | 36 | ||||
-rw-r--r-- | src/webex/pages/payback.tsx | 100 | ||||
-rw-r--r-- | src/webex/pages/popup.css | 84 | ||||
-rw-r--r-- | src/webex/pages/popup.html | 18 | ||||
-rw-r--r-- | src/webex/pages/popup.tsx | 548 | ||||
-rw-r--r-- | src/webex/pages/show-db.html | 18 | ||||
-rw-r--r-- | src/webex/pages/show-db.ts | 94 | ||||
-rw-r--r-- | src/webex/pages/tree.html | 27 | ||||
-rw-r--r-- | src/webex/pages/tree.tsx | 437 |
22 files changed, 2930 insertions, 0 deletions
diff --git a/src/webex/pages/add-auditor.html b/src/webex/pages/add-auditor.html new file mode 100644 index 000000000..b7a9d041d --- /dev/null +++ b/src/webex/pages/add-auditor.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + + <title>Taler Wallet: Add Auditor</title> + + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + + <link rel="icon" href="/img/icon.png"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/add-auditor-bundle.js"></script> + + <style> + .tree-item { + margin: 2em; + border-radius: 5px; + border: 1px solid gray; + padding: 1em; + } + .button-linky { + background: none; + color: black; + text-decoration: underline; + border: none; + } + </style> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/src/webex/pages/add-auditor.tsx b/src/webex/pages/add-auditor.tsx new file mode 100644 index 000000000..c1a9f997f --- /dev/null +++ b/src/webex/pages/add-auditor.tsx @@ -0,0 +1,126 @@ +/* + This file is part of TALER + (C) 2017 Inria + + 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 <http://www.gnu.org/licenses/> + */ + +/** + * View and edit auditors. + * + * @author Florian Dold + */ + + +import { getTalerStampDate } from "../../helpers"; +import { + ExchangeRecord, + DenominationRecord, + AuditorRecord, + CurrencyRecord, + ReserveRecord, + CoinRecord, + PreCoinRecord, + Denomination +} from "../../types"; + +import { ImplicitStateComponent, StateHolder } from "../components"; +import { + getCurrencies, + updateCurrency, +} from "../wxApi"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import URI = require("urijs"); + +interface ConfirmAuditorProps { + url: string; + currency: string; + auditorPub: string; + expirationStamp: number; +} + +class ConfirmAuditor extends ImplicitStateComponent<ConfirmAuditorProps> { + addDone: StateHolder<boolean> = this.makeState(false); + constructor() { + super(); + } + + async add() { + let currencies = await getCurrencies(); + let currency: CurrencyRecord|undefined = undefined; + + for (let c of currencies) { + if (c.name == this.props.currency) { + currency = c; + } + } + + if (!currency) { + currency = { name: this.props.currency, auditors: [], fractionalDigits: 2, exchanges: [] }; + } + + let newAuditor = { auditorPub: this.props.auditorPub, baseUrl: this.props.url, expirationStamp: this.props.expirationStamp }; + + let auditorFound = false; + for (let idx in currency.auditors) { + let a = currency.auditors[idx]; + if (a.baseUrl == this.props.url) { + auditorFound = true; + // Update auditor if already found by URL. + currency.auditors[idx] = newAuditor; + } + } + + if (!auditorFound) { + currency.auditors.push(newAuditor); + } + + await updateCurrency(currency); + + this.addDone(true); + } + + back() { + window.history.back(); + } + + render(): JSX.Element { + return ( + <div id="main"> + <p>Do you want to let <strong>{this.props.auditorPub}</strong> audit the currency "{this.props.currency}"?</p> + {this.addDone() ? + (<div>Auditor was added! You can also <a href={chrome.extension.getURL("/src/pages/auditors.html")}>view and edit</a> auditors.</div>) + : + (<div> + <button onClick={() => this.add()} className="pure-button pure-button-primary">Yes</button> + <button onClick={() => this.back()} className="pure-button">No</button> + </div>) + } + </div> + ); + } +} + +export function main() { + const walletPageUrl = new URI(document.location.href); + const query: any = JSON.parse((URI.parseQuery(walletPageUrl.query()) as any)["req"]); + const url = query.url; + const currency: string = query.currency; + const auditorPub: string = query.auditorPub; + const expirationStamp = Number.parseInt(query.expirationStamp); + const args = { url, currency, auditorPub, expirationStamp }; + ReactDOM.render(<ConfirmAuditor {...args} />, document.getElementById("container")!); +} + +document.addEventListener("DOMContentLoaded", main); diff --git a/src/webex/pages/auditors.html b/src/webex/pages/auditors.html new file mode 100644 index 000000000..cbfc3b4b5 --- /dev/null +++ b/src/webex/pages/auditors.html @@ -0,0 +1,36 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <title>Taler Wallet: Auditors</title> + + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + + <link rel="icon" href="/img/icon.png"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/auditors-bundle.js"></script> + + <style> + body { + font-size: 100%; + } + .tree-item { + margin: 2em; + border-radius: 5px; + border: 1px solid gray; + padding: 1em; + } + .button-linky { + background: none; + color: black; + text-decoration: underline; + border: none; + } + </style> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/src/webex/pages/auditors.tsx b/src/webex/pages/auditors.tsx new file mode 100644 index 000000000..dac3c2be9 --- /dev/null +++ b/src/webex/pages/auditors.tsx @@ -0,0 +1,147 @@ +/* + This file is part of TALER + (C) 2017 Inria + + 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 <http://www.gnu.org/licenses/> + */ + +/** + * View and edit auditors. + * + * @author Florian Dold + */ + + +import { getTalerStampDate } from "../../helpers"; +import { + ExchangeRecord, + ExchangeForCurrencyRecord, + DenominationRecord, + AuditorRecord, + CurrencyRecord, + ReserveRecord, + CoinRecord, + PreCoinRecord, + Denomination +} from "../../types"; + +import { ImplicitStateComponent, StateHolder } from "../components"; +import { + getCurrencies, + updateCurrency, +} from "../wxApi"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; + +interface CurrencyListState { + currencies?: CurrencyRecord[]; +} + +class CurrencyList extends React.Component<any, CurrencyListState> { + constructor() { + super(); + let port = chrome.runtime.connect(); + port.onMessage.addListener((msg: any) => { + if (msg.notify) { + console.log("got notified"); + this.update(); + } + }); + this.update(); + this.state = {} as any; + } + + async update() { + let currencies = await getCurrencies(); + console.log("currencies: ", currencies); + this.setState({ currencies }); + } + + async confirmRemoveAuditor(c: CurrencyRecord, a: AuditorRecord) { + if (window.confirm(`Do you really want to remove auditor ${a.baseUrl} for currency ${c.name}?`)) { + c.auditors = c.auditors.filter((x) => x.auditorPub != a.auditorPub); + await updateCurrency(c); + } + } + + async confirmRemoveExchange(c: CurrencyRecord, e: ExchangeForCurrencyRecord) { + if (window.confirm(`Do you really want to remove exchange ${e.baseUrl} for currency ${c.name}?`)) { + c.exchanges = c.exchanges.filter((x) => x.baseUrl != e.baseUrl); + await updateCurrency(c); + } + } + + renderAuditors(c: CurrencyRecord): any { + if (c.auditors.length == 0) { + return <p>No trusted auditors for this currency.</p> + } + return ( + <div> + <p>Trusted Auditors:</p> + <ul> + {c.auditors.map(a => ( + <li>{a.baseUrl} <button className="pure-button button-destructive" onClick={() => this.confirmRemoveAuditor(c, a)}>Remove</button> + <ul> + <li>valid until {new Date(a.expirationStamp).toString()}</li> + <li>public key {a.auditorPub}</li> + </ul> + </li> + ))} + </ul> + </div> + ); + } + + renderExchanges(c: CurrencyRecord): any { + if (c.exchanges.length == 0) { + return <p>No trusted exchanges for this currency.</p> + } + return ( + <div> + <p>Trusted Exchanges:</p> + <ul> + {c.exchanges.map(e => ( + <li>{e.baseUrl} <button className="pure-button button-destructive" onClick={() => this.confirmRemoveExchange(c, e)}>Remove</button> + </li> + ))} + </ul> + </div> + ); + } + + render(): JSX.Element { + let currencies = this.state.currencies; + if (!currencies) { + return <span>...</span>; + } + return ( + <div id="main"> + {currencies.map(c => ( + <div> + <h1>Currency {c.name}</h1> + <p>Displayed with {c.fractionalDigits} fractional digits.</p> + <h2>Auditors</h2> + <div>{this.renderAuditors(c)}</div> + <h2>Exchanges</h2> + <div>{this.renderExchanges(c)}</div> + </div> + ))} + </div> + ); + } +} + +export function main() { + ReactDOM.render(<CurrencyList />, document.getElementById("container")!); +} + +document.addEventListener("DOMContentLoaded", main); diff --git a/src/webex/pages/confirm-contract.html b/src/webex/pages/confirm-contract.html new file mode 100644 index 000000000..6713b2e2c --- /dev/null +++ b/src/webex/pages/confirm-contract.html @@ -0,0 +1,69 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <title>Taler Wallet: Confirm Reserve Creation</title> + + <link rel="stylesheet" type="text/css" href="/src/style/wallet.css"> + + <link rel="icon" href="/img/icon.png"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/confirm-contract-bundle.js"></script> + + <style> + button.accept { + background-color: #5757D2; + border: 1px solid black; + border-radius: 5px; + margin: 1em 0; + padding: 0.5em; + font-weight: bold; + color: white; + } + button.linky { + background:none!important; + border:none; + padding:0!important; + + font-family:arial,sans-serif; + color:#069; + text-decoration:underline; + cursor:pointer; + } + + input.url { + width: 25em; + } + + + button.accept:disabled { + background-color: #dedbe8; + border: 1px solid white; + border-radius: 5px; + margin: 1em 0; + padding: 0.5em; + font-weight: bold; + color: #2C2C2C; + } + + .errorbox { + border: 1px solid; + display: inline-block; + margin: 1em; + padding: 1em; + font-weight: bold; + background: #FF8A8A; + } + </style> +</head> + +<body> + <section id="main"> + <h1>GNU Taler Wallet</h1> + <article id="contract" class="fade"></article> + </section> +</body> + +</html> diff --git a/src/webex/pages/confirm-contract.tsx b/src/webex/pages/confirm-contract.tsx new file mode 100644 index 000000000..011df27a1 --- /dev/null +++ b/src/webex/pages/confirm-contract.tsx @@ -0,0 +1,242 @@ +/* + This file is part of TALER + (C) 2015 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 <http://www.gnu.org/licenses/> + */ + +/** + * Page shown to the user to confirm entering + * a contract. + */ + + +/** + * Imports. + */ +import * as i18n from "../../i18n"; +import { Contract, AmountJson, ExchangeRecord } from "../../types"; +import { OfferRecord } from "../../wallet"; + +import { renderContract } from "../renderHtml"; +import { getExchanges } from "../wxApi"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import URI = require("urijs"); + + +interface DetailState { + collapsed: boolean; +} + +interface DetailProps { + contract: Contract + collapsed: boolean + exchanges: null|ExchangeRecord[]; +} + + +class Details extends React.Component<DetailProps, DetailState> { + constructor(props: DetailProps) { + super(props); + console.log("new Details component created"); + this.state = { + collapsed: props.collapsed, + }; + + console.log("initial state:", this.state); + } + + render() { + if (this.state.collapsed) { + return ( + <div> + <button className="linky" + onClick={() => { this.setState({collapsed: false} as any)}}> + <i18n.Translate wrap="span"> + show more details + </i18n.Translate> + </button> + </div> + ); + } else { + return ( + <div> + <button className="linky" + onClick={() => this.setState({collapsed: true} as any)}> + show less details + </button> + <div> + {i18n.str`Accepted exchanges:`} + <ul> + {this.props.contract.exchanges.map( + e => <li>{`${e.url}: ${e.master_pub}`}</li>)} + </ul> + {i18n.str`Exchanges in the wallet:`} + <ul> + {(this.props.exchanges || []).map( + (e: ExchangeRecord) => + <li>{`${e.baseUrl}: ${e.masterPublicKey}`}</li>)} + </ul> + </div> + </div>); + } + } +} + +interface ContractPromptProps { + offerId: number; +} + +interface ContractPromptState { + offer: OfferRecord|null; + error: string|null; + payDisabled: boolean; + exchanges: null|ExchangeRecord[]; +} + +class ContractPrompt extends React.Component<ContractPromptProps, ContractPromptState> { + constructor() { + super(); + this.state = { + offer: null, + error: null, + payDisabled: true, + exchanges: null + } + } + + componentWillMount() { + this.update(); + } + + componentWillUnmount() { + // FIXME: abort running ops + } + + async update() { + let offer = await this.getOffer(); + this.setState({offer} as any); + this.checkPayment(); + let exchanges = await getExchanges(); + this.setState({exchanges} as any); + } + + getOffer(): Promise<OfferRecord> { + return new Promise<OfferRecord>((resolve, reject) => { + let msg = { + type: 'get-offer', + detail: { + offerId: this.props.offerId + } + }; + chrome.runtime.sendMessage(msg, (resp) => { + resolve(resp); + }); + }) + } + + checkPayment() { + let msg = { + type: 'check-pay', + detail: { + offer: this.state.offer + } + }; + chrome.runtime.sendMessage(msg, (resp) => { + if (resp.error) { + console.log("check-pay error", JSON.stringify(resp)); + switch (resp.error) { + case "coins-insufficient": + let msgInsufficient = i18n.str`You have insufficient funds of the requested currency in your wallet.`; + let msgNoMatch = i18n.str`You do not have any funds from an exchange that is accepted by this merchant. None of the exchanges accepted by the merchant is known to your wallet.`; + if (this.state.exchanges && this.state.offer) { + let acceptedExchangePubs = this.state.offer.contract.exchanges.map((e) => e.master_pub); + let ex = this.state.exchanges.find((e) => acceptedExchangePubs.indexOf(e.masterPublicKey) >= 0); + if (ex) { + this.setState({error: msgInsufficient}); + } else { + this.setState({error: msgNoMatch}); + } + } else { + this.setState({error: msgInsufficient}); + } + break; + default: + this.setState({error: `Error: ${resp.error}`}); + break; + } + this.setState({payDisabled: true}); + } else { + this.setState({payDisabled: false, error: null}); + } + this.setState({} as any); + window.setTimeout(() => this.checkPayment(), 500); + }); + } + + doPayment() { + let d = {offer: this.state.offer}; + chrome.runtime.sendMessage({type: 'confirm-pay', detail: d}, (resp) => { + if (resp.error) { + console.log("confirm-pay error", JSON.stringify(resp)); + switch (resp.error) { + case "coins-insufficient": + this.setState({error: "You do not have enough coins of the requested currency."}); + break; + default: + this.setState({error: `Error: ${resp.error}`}); + break; + } + return; + } + let c = d.offer!.contract; + console.log("contract", c); + document.location.href = c.fulfillment_url; + }); + } + + + render() { + if (!this.state.offer) { + return <span>...</span>; + } + let c = this.state.offer.contract; + return ( + <div> + <div> + {renderContract(c)} + </div> + <button onClick={() => this.doPayment()} + disabled={this.state.payDisabled} + className="accept"> + Confirm payment + </button> + <div> + {(this.state.error ? <p className="errorbox">{this.state.error}</p> : <p />)} + </div> + <Details exchanges={this.state.exchanges} contract={c} collapsed={!this.state.error}/> + </div> + ); + } +} + + +document.addEventListener("DOMContentLoaded", () => { + let url = new URI(document.location.href); + let query: any = URI.parseQuery(url.query()); + let offerId = JSON.parse(query.offerId); + + ReactDOM.render(<ContractPrompt offerId={offerId}/>, document.getElementById( + "contract")!); +}); diff --git a/src/webex/pages/confirm-create-reserve.html b/src/webex/pages/confirm-create-reserve.html new file mode 100644 index 000000000..16ab12a30 --- /dev/null +++ b/src/webex/pages/confirm-create-reserve.html @@ -0,0 +1,52 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <title>Taler Wallet: Select Taler Provider</title> + + <link rel="icon" href="/img/icon.png"> + <link rel="stylesheet" type="text/css" href="/src/style/wallet.css"> + <link rel="stylesheet" type="text/css" href="/src/style/pure.css"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/confirm-create-reserve-bundle.js"></script> + + <style> + body { + font-size: 100%; + overflow-y: scroll; + } + .button-success { + background: rgb(28, 184, 65); /* this is a green */ + color: white; + border-radius: 4px; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); + } + .button-secondary { + background: rgb(66, 184, 221); /* this is a light blue */ + color: white; + border-radius: 4px; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); + } + a.opener { + color: black; + } + .opener-open::before { + content: "\25bc" + } + .opener-collapsed::before { + content: "\25b6 " + } + </style> + +</head> + +<body> + <section id="main"> + <h1>GNU Taler Wallet</h1> + <div class="fade" id="exchange-selection"></div> + </section> +</body> + +</html> diff --git a/src/webex/pages/confirm-create-reserve.tsx b/src/webex/pages/confirm-create-reserve.tsx new file mode 100644 index 000000000..6ece92e21 --- /dev/null +++ b/src/webex/pages/confirm-create-reserve.tsx @@ -0,0 +1,641 @@ +/* + This file is part of TALER + (C) 2015-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 <http://www.gnu.org/licenses/> + */ + + +/** + * Page shown to the user to confirm creation + * of a reserve, usually requested by the bank. + * + * @author Florian Dold + */ + +import {amountToPretty, canonicalizeBaseUrl} from "../../helpers"; +import { + AmountJson, CreateReserveResponse, + ReserveCreationInfo, Amounts, + Denomination, DenominationRecord, CurrencyRecord +} from "../../types"; +import * as i18n from "../../i18n"; + +import {getReserveCreationInfo, getCurrency, getExchangeInfo} from "../wxApi"; +import {ImplicitStateComponent, StateHolder} from "../components"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import URI = require("urijs"); +import * as moment from "moment"; + + +function delay<T>(delayMs: number, value: T): Promise<T> { + return new Promise<T>((resolve, reject) => { + setTimeout(() => resolve(value), delayMs); + }); +} + +class EventTrigger { + triggerResolve: any; + triggerPromise: Promise<boolean>; + + constructor() { + this.reset(); + } + + private reset() { + this.triggerPromise = new Promise<boolean>((resolve, reject) => { + this.triggerResolve = resolve; + }); + } + + trigger() { + this.triggerResolve(false); + this.reset(); + } + + async wait(delayMs: number): Promise<boolean> { + return await Promise.race([this.triggerPromise, delay(delayMs, true)]); + } +} + + +interface CollapsibleState { + collapsed: boolean; +} + +interface CollapsibleProps { + initiallyCollapsed: boolean; + title: string; +} + +class Collapsible extends React.Component<CollapsibleProps, CollapsibleState> { + constructor(props: CollapsibleProps) { + super(props); + this.state = { collapsed: props.initiallyCollapsed }; + } + render() { + const doOpen = (e: any) => { + this.setState({collapsed: false}) + e.preventDefault() + }; + const doClose = (e: any) => { + this.setState({collapsed: true}) + e.preventDefault(); + }; + if (this.state.collapsed) { + return <h2><a className="opener opener-collapsed" href="#" onClick={doOpen}>{this.props.title}</a></h2>; + } + return ( + <div> + <h2><a className="opener opener-open" href="#" onClick={doClose}>{this.props.title}</a></h2> + {this.props.children} + </div> + ); + } +} + +function renderAuditorDetails(rci: ReserveCreationInfo|null) { + if (!rci) { + return ( + <p> + Details will be displayed when a valid exchange provider URL is entered. + </p> + ); + } + if (rci.exchangeInfo.auditors.length == 0) { + return ( + <p> + The exchange is not audited by any auditors. + </p> + ); + } + return ( + <div> + {rci.exchangeInfo.auditors.map(a => ( + <h3>Auditor {a.url}</h3> + ))} + </div> + ); +} + +function renderReserveCreationDetails(rci: ReserveCreationInfo|null) { + if (!rci) { + return ( + <p> + Details will be displayed when a valid exchange provider URL is entered. + </p> + ); + } + + let denoms = rci.selectedDenoms; + + let countByPub: {[s: string]: number} = {}; + let uniq: DenominationRecord[] = []; + + denoms.forEach((x: DenominationRecord) => { + let c = countByPub[x.denomPub] || 0; + if (c == 0) { + uniq.push(x); + } + c += 1; + countByPub[x.denomPub] = c; + }); + + function row(denom: DenominationRecord) { + return ( + <tr> + <td>{countByPub[denom.denomPub] + "x"}</td> + <td>{amountToPretty(denom.value)}</td> + <td>{amountToPretty(denom.feeWithdraw)}</td> + <td>{amountToPretty(denom.feeRefresh)}</td> + <td>{amountToPretty(denom.feeDeposit)}</td> + </tr> + ); + } + + function wireFee(s: string) { + return [ + <thead> + <tr> + <th colSpan={3}>Wire Method {s}</th> + </tr> + <tr> + <th>Applies Until</th> + <th>Wire Fee</th> + <th>Closing Fee</th> + </tr> + </thead>, + <tbody> + {rci!.wireFees.feesForType[s].map(f => ( + <tr> + <td>{moment.unix(f.endStamp).format("llll")}</td> + <td>{amountToPretty(f.wireFee)}</td> + <td>{amountToPretty(f.closingFee)}</td> + </tr> + ))} + </tbody> + ]; + } + + let withdrawFeeStr = amountToPretty(rci.withdrawFee); + let overheadStr = amountToPretty(rci.overhead); + + return ( + <div> + <h3>Overview</h3> + <p>{i18n.str`Withdrawal fees: ${withdrawFeeStr}`}</p> + <p>{i18n.str`Rounding loss: ${overheadStr}`}</p> + <p>{i18n.str`Earliest expiration (for deposit): ${moment.unix(rci.earliestDepositExpiration).fromNow()}`}</p> + <h3>Coin Fees</h3> + <table className="pure-table"> + <thead> + <tr> + <th>{i18n.str`# Coins`}</th> + <th>{i18n.str`Value`}</th> + <th>{i18n.str`Withdraw Fee`}</th> + <th>{i18n.str`Refresh Fee`}</th> + <th>{i18n.str`Deposit Fee`}</th> + </tr> + </thead> + <tbody> + {uniq.map(row)} + </tbody> + </table> + <h3>Wire Fees</h3> + <table className="pure-table"> + {Object.keys(rci.wireFees.feesForType).map(wireFee)} + </table> + </div> + ); +} + + +function getSuggestedExchange(currency: string): Promise<string> { + // TODO: make this request go to the wallet backend + // Right now, this is a stub. + const defaultExchange: {[s: string]: string} = { + "KUDOS": "https://exchange.demo.taler.net", + "PUDOS": "https://exchange.test.taler.net", + }; + + let exchange = defaultExchange[currency]; + + if (!exchange) { + exchange = "" + } + + return Promise.resolve(exchange); +} + + +function WithdrawFee(props: {reserveCreationInfo: ReserveCreationInfo|null}): JSX.Element { + if (props.reserveCreationInfo) { + let {overhead, withdrawFee} = props.reserveCreationInfo; + let totalCost = Amounts.add(overhead, withdrawFee).amount; + return <p>{i18n.str`Withdraw fees:`} {amountToPretty(totalCost)}</p>; + } + return <p />; +} + + +interface ExchangeSelectionProps { + suggestedExchangeUrl: string; + amount: AmountJson; + callback_url: string; + wt_types: string[]; + currencyRecord: CurrencyRecord|null; +} + +interface ManualSelectionProps { + onSelect(url: string): void; + initialUrl: string; +} + +class ManualSelection extends ImplicitStateComponent<ManualSelectionProps> { + url: StateHolder<string> = this.makeState(""); + errorMessage: StateHolder<string|null> = this.makeState(null); + isOkay: StateHolder<boolean> = this.makeState(false); + updateEvent = new EventTrigger(); + constructor(p: ManualSelectionProps) { + super(p); + this.url(p.initialUrl); + this.update(); + } + render() { + return ( + <div className="pure-g pure-form pure-form-stacked"> + <div className="pure-u-1"> + <label>URL</label> + <input className="url" type="text" spellCheck={false} + value={this.url()} + key="exchange-url-input" + onInput={(e) => this.onUrlChanged((e.target as HTMLInputElement).value)} /> + </div> + <div className="pure-u-1"> + <button className="pure-button button-success" + disabled={!this.isOkay()} + onClick={() => this.props.onSelect(this.url())}> + {i18n.str`Select`} + </button> + {this.errorMessage()} + </div> + </div> + ); + } + + async update() { + this.errorMessage(null); + this.isOkay(false); + if (!this.url()) { + return; + } + let parsedUrl = new URI(this.url()!); + if (parsedUrl.is("relative")) { + this.errorMessage(i18n.str`Error: URL may not be relative`); + this.isOkay(false); + return; + } + try { + let url = canonicalizeBaseUrl(this.url()!); + let r = await getExchangeInfo(url) + console.log("getExchangeInfo returned") + this.isOkay(true); + } catch (e) { + console.log("got error", e); + if (e.hasOwnProperty("httpStatus")) { + this.errorMessage(`Error: request failed with status ${e.httpStatus}`); + } else if (e.hasOwnProperty("errorResponse")) { + let resp = e.errorResponse; + this.errorMessage(`Error: ${resp.error} (${resp.hint})`); + } else { + this.errorMessage("invalid exchange URL"); + } + } + } + + async onUrlChanged(s: string) { + this.url(s); + this.errorMessage(null); + this.isOkay(false); + this.updateEvent.trigger(); + let waited = await this.updateEvent.wait(200); + if (waited) { + // Run the actual update if nobody else preempted us. + this.update(); + } + } +} + + +class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> { + statusString: StateHolder<string|null> = this.makeState(null); + reserveCreationInfo: StateHolder<ReserveCreationInfo|null> = this.makeState( + null); + url: StateHolder<string|null> = this.makeState(null); + + selectingExchange: StateHolder<boolean> = this.makeState(false); + + constructor(props: ExchangeSelectionProps) { + super(props); + let prefilledExchangesUrls = []; + if (props.currencyRecord) { + let exchanges = props.currencyRecord.exchanges.map((x) => x.baseUrl); + prefilledExchangesUrls.push(...exchanges); + } + if (props.suggestedExchangeUrl) { + prefilledExchangesUrls.push(props.suggestedExchangeUrl); + } + if (prefilledExchangesUrls.length != 0) { + this.url(prefilledExchangesUrls[0]); + this.forceReserveUpdate(); + } else { + this.selectingExchange(true); + } + } + + renderFeeStatus() { + let rci = this.reserveCreationInfo(); + if (rci) { + let totalCost = Amounts.add(rci.overhead, rci.withdrawFee).amount; + let trustMessage; + if (rci.isTrusted) { + trustMessage = ( + <i18n.Translate wrap="p"> + The exchange is trusted by the wallet. + </i18n.Translate> + ); + } else if (rci.isAudited) { + trustMessage = ( + <i18n.Translate wrap="p"> + The exchange is audited by a trusted auditor. + </i18n.Translate> + ); + } else { + trustMessage = ( + <i18n.Translate wrap="p"> + Warning: The exchange is neither directly trusted nor audited by a trusted auditor. + If you withdraw from this exchange, it will be trusted in the future. + </i18n.Translate> + ); + } + return ( + <div> + <i18n.Translate wrap="p"> + Using exchange provider <strong>{this.url()}</strong>. + The exchange provider will charge + {" "} + <span>{amountToPretty(totalCost)}</span> + {" "} + in fees. + </i18n.Translate> + {trustMessage} + </div> + ); + } + if (this.url() && !this.statusString()) { + let shortName = new URI(this.url()!).host(); + return ( + <i18n.Translate wrap="p"> + Waiting for a response from + {" "} + <em>{shortName}</em> + </i18n.Translate> + ); + } + if (this.statusString()) { + return ( + <p> + <strong style={{color: "red"}}>{i18n.str`A problem occured, see below. ${this.statusString()}`}</strong> + </p> + ); + } + return ( + <p> + {i18n.str`Information about fees will be available when an exchange provider is selected.`} + </p> + ); + } + + renderConfirm() { + return ( + <div> + {this.renderFeeStatus()} + <button className="pure-button button-success" + disabled={this.reserveCreationInfo() == null} + onClick={() => this.confirmReserve()}> + {i18n.str`Accept fees and withdraw`} + </button> + { " " } + <button className="pure-button button-secondary" + onClick={() => this.selectingExchange(true)}> + {i18n.str`Change Exchange Provider`} + </button> + <br/> + <Collapsible initiallyCollapsed={true} title="Fee and Spending Details"> + {renderReserveCreationDetails(this.reserveCreationInfo())} + </Collapsible> + <Collapsible initiallyCollapsed={true} title="Auditor Details"> + {renderAuditorDetails(this.reserveCreationInfo())} + </Collapsible> + </div> + ); + } + + select(url: string) { + this.reserveCreationInfo(null); + this.url(url); + this.selectingExchange(false); + this.forceReserveUpdate(); + } + + renderSelect() { + let exchanges = (this.props.currencyRecord && this.props.currencyRecord.exchanges) || []; + console.log(exchanges); + return ( + <div> + Please select an exchange. You can review the details before after your selection. + + {this.props.suggestedExchangeUrl && ( + <div> + <h2>Bank Suggestion</h2> + <button className="pure-button button-success" onClick={() => this.select(this.props.suggestedExchangeUrl)}> + Select <strong>{this.props.suggestedExchangeUrl}</strong> + </button> + </div> + )} + + {exchanges.length > 0 && ( + <div> + <h2>Known Exchanges</h2> + {exchanges.map(e => ( + <button className="pure-button button-success" onClick={() => this.select(e.baseUrl)}> + Select <strong>{e.baseUrl}</strong> + </button> + ))} + </div> + )} + + <h2>Manual Selection</h2> + <ManualSelection initialUrl={this.url() || ""} onSelect={(url: string) => this.select(url)} /> + </div> + ); + } + + render(): JSX.Element { + return ( + <div> + <i18n.Translate wrap="p"> + {"You are about to withdraw "} + <strong>{amountToPretty(this.props.amount)}</strong> + {" from your bank account into your wallet."} + </i18n.Translate> + {this.selectingExchange() ? this.renderSelect() : this.renderConfirm()} + </div> + ); + } + + + confirmReserve() { + this.confirmReserveImpl(this.reserveCreationInfo()!, + this.url()!, + this.props.amount, + this.props.callback_url); + } + + /** + * Do an update of the reserve creation info, without any debouncing. + */ + async forceReserveUpdate() { + this.reserveCreationInfo(null); + try { + let url = canonicalizeBaseUrl(this.url()!); + let r = await getReserveCreationInfo(url, + this.props.amount); + console.log("get exchange info resolved"); + this.reserveCreationInfo(r); + console.dir(r); + } catch (e) { + console.log("get exchange info rejected", e); + if (e.hasOwnProperty("httpStatus")) { + this.statusString(`Error: request failed with status ${e.httpStatus}`); + } else if (e.hasOwnProperty("errorResponse")) { + let resp = e.errorResponse; + this.statusString(`Error: ${resp.error} (${resp.hint})`); + } + } + } + + confirmReserveImpl(rci: ReserveCreationInfo, + exchange: string, + amount: AmountJson, + callback_url: string) { + const d = {exchange: canonicalizeBaseUrl(exchange), amount}; + const cb = (rawResp: any) => { + if (!rawResp) { + throw Error("empty response"); + } + // FIXME: filter out types that bank/exchange don't have in common + let wireDetails = rci.wireInfo; + let filteredWireDetails: any = {}; + for (let wireType in wireDetails) { + if (this.props.wt_types.findIndex((x) => x.toLowerCase() == wireType.toLowerCase()) < 0) { + continue; + } + let obj = Object.assign({}, wireDetails[wireType]); + // The bank doesn't need to know about fees + delete obj.fees; + // Consequently the bank can't verify signatures anyway, so + // we delete this extra data, to make the request URL shorter. + delete obj.salt; + delete obj.sig; + filteredWireDetails[wireType] = obj; + } + if (!rawResp.error) { + const resp = CreateReserveResponse.checked(rawResp); + let q: {[name: string]: string|number} = { + wire_details: JSON.stringify(filteredWireDetails), + exchange: resp.exchange, + reserve_pub: resp.reservePub, + amount_value: amount.value, + amount_fraction: amount.fraction, + amount_currency: amount.currency, + }; + let url = new URI(callback_url).addQuery(q); + if (!url.is("absolute")) { + throw Error("callback url is not absolute"); + } + console.log("going to", url.href()); + document.location.href = url.href(); + } else { + this.statusString( + i18n.str`Oops, something went wrong. The wallet responded with error status (${rawResp.error}).`); + } + }; + chrome.runtime.sendMessage({type: 'create-reserve', detail: d}, cb); + } + + renderStatus(): any { + if (this.statusString()) { + return <p><strong style={{color: "red"}}>{this.statusString()}</strong></p>; + } else if (!this.reserveCreationInfo()) { + return <p>{i18n.str`Checking URL, please wait ...`}</p>; + } + return ""; + } +} + +export async function main() { + try { + const url = new URI(document.location.href); + const query: any = URI.parseQuery(url.query()); + let amount; + try { + amount = AmountJson.checked(JSON.parse(query.amount)); + } catch (e) { + throw Error(i18n.str`Can't parse amount: ${e.message}`); + } + const callback_url = query.callback_url; + const bank_url = query.bank_url; + let wt_types; + try { + wt_types = JSON.parse(query.wt_types); + } catch (e) { + throw Error(i18n.str`Can't parse wire_types: ${e.message}`); + } + + let suggestedExchangeUrl = query.suggested_exchange_url; + let currencyRecord = await getCurrency(amount.currency); + + let args = { + wt_types, + suggestedExchangeUrl, + callback_url, + amount, + currencyRecord, + }; + + ReactDOM.render(<ExchangeSelection {...args} />, document.getElementById( + "exchange-selection")!); + + } catch (e) { + // TODO: provide more context information, maybe factor it out into a + // TODO:generic error reporting function or component. + document.body.innerText = i18n.str`Fatal error: "${e.message}".`; + console.error(`got error "${e.message}"`, e); + } +} + +document.addEventListener("DOMContentLoaded", () => { + main(); +}); diff --git a/src/webex/pages/error.html b/src/webex/pages/error.html new file mode 100644 index 000000000..51a8fd73a --- /dev/null +++ b/src/webex/pages/error.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <title>Taler Wallet: Error Occured</title> + + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + + <link rel="icon" href="/img/icon.png"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/error-bundle.js"></script> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/src/webex/pages/error.tsx b/src/webex/pages/error.tsx new file mode 100644 index 000000000..f278bd224 --- /dev/null +++ b/src/webex/pages/error.tsx @@ -0,0 +1,63 @@ +/* + This file is part of TALER + (C) 2015-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 <http://www.gnu.org/licenses/> + */ + + +/** + * Page shown to the user to confirm creation + * of a reserve, usually requested by the bank. + * + * @author Florian Dold + */ + +import {ImplicitStateComponent, StateHolder} from "../components"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import URI = require("urijs"); + +"use strict"; + +interface ErrorProps { + message: string; +} + +class ErrorView extends React.Component<ErrorProps, void> { + render(): JSX.Element { + return ( + <div> + An error occurred: {this.props.message} + </div> + ); + } +} + +export async function main() { + try { + const url = new URI(document.location.href); + const query: any = URI.parseQuery(url.query()); + + const message: string = query.message || "unknown error"; + + ReactDOM.render(<ErrorView message={message} />, document.getElementById( + "container")!); + + } catch (e) { + // TODO: provide more context information, maybe factor it out into a + // TODO:generic error reporting function or component. + document.body.innerText = `Fatal error: "${e.message}".`; + console.error(`got error "${e.message}"`, e); + } +} diff --git a/src/webex/pages/help/empty-wallet.html b/src/webex/pages/help/empty-wallet.html new file mode 100644 index 000000000..dd29d9689 --- /dev/null +++ b/src/webex/pages/help/empty-wallet.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <title>GNU Taler Help - Empty Wallet</title> + <link rel="icon" href="/img/icon.png"> + <meta name="description" content=""> + <link rel="stylesheet" type="text/css" href="/src/style/wallet.css"> + </head> + <body> + <div class="container" id="main"> + <div class="row"> + <div class="col-lg-12"> + <h2 lang="en">Your wallet is empty!</h2> + <p lang="en">You have succeeded with installing the Taler wallet. However, before + you can buy articles using the Taler wallet, you must withdraw electronic coins. + This is typically done by visiting your bank's online banking Web site. There, + you instruct your bank to transfer the funds to a Taler exchange operator. In + return, your wallet will be allowed to withdraw electronic coins.</p> + <p lang="en">At this stage, we are not aware of any regular exchange operators issuing + coins in well-known currencies. However, to see how Taler would work, you + can visit our "fake" bank at + <a href="https://bank.demo.taler.net/">bank.demo.taler.net</a> to + withdraw coins in the "KUDOS" currency that we created just for + demonstrating the system.</p> + </div> + </div> + </div> + </body> +</html> diff --git a/src/webex/pages/logs.html b/src/webex/pages/logs.html new file mode 100644 index 000000000..9545269e3 --- /dev/null +++ b/src/webex/pages/logs.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <title>Taler Wallet: Logs</title> + + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + + <link rel="icon" href="/img/icon.png"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/logs-bundle.js"></script> + + <style> + .tree-item { + margin: 2em; + border-radius: 5px; + border: 1px solid gray; + padding: 1em; + } + </style> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/src/webex/pages/logs.tsx b/src/webex/pages/logs.tsx new file mode 100644 index 000000000..0c533bfa8 --- /dev/null +++ b/src/webex/pages/logs.tsx @@ -0,0 +1,83 @@ +/* + This file is part of TALER + (C) 2016 Inria + + 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 <http://www.gnu.org/licenses/> + */ + +/** + * Show wallet logs. + * + * @author Florian Dold + */ + +import {LogEntry, getLogs} from "../../logging"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; + +interface LogViewProps { + log: LogEntry; +} + +class LogView extends React.Component<LogViewProps, void> { + render(): JSX.Element { + let e = this.props.log; + return ( + <div className="tree-item"> + <ul> + <li>level: {e.level}</li> + <li>msg: {e.msg}</li> + <li>id: {e.id || "unknown"}</li> + <li>file: {e.source || "(unknown)"}</li> + <li>line: {e.line || "(unknown)"}</li> + <li>col: {e.col || "(unknown)"}</li> + {(e.detail ? <li> detail: <pre>{e.detail}</pre></li> : [])} + </ul> + </div> + ); + } +} + +interface LogsState { + logs: LogEntry[]|undefined; +} + +class Logs extends React.Component<any, LogsState> { + constructor() { + super(); + this.update(); + this.state = {} as any; + } + + async update() { + let logs = await getLogs(); + this.setState({logs}); + } + + render(): JSX.Element { + let logs = this.state.logs; + if (!logs) { + return <span>...</span>; + } + return ( + <div className="tree-item"> + Logs: + {logs.map(e => <LogView log={e} />)} + </div> + ); + } +} + +document.addEventListener("DOMContentLoaded", () => { + ReactDOM.render(<Logs />, document.getElementById("container")!); +}); diff --git a/src/webex/pages/payback.html b/src/webex/pages/payback.html new file mode 100644 index 000000000..d6fe334c8 --- /dev/null +++ b/src/webex/pages/payback.html @@ -0,0 +1,36 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <title>Taler Wallet: Payback</title> + + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + + <link rel="icon" href="/img/icon.png"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/payback-bundle.js"></script> + + <style> + body { + font-size: 100%; + } + .tree-item { + margin: 2em; + border-radius: 5px; + border: 1px solid gray; + padding: 1em; + } + .button-linky { + background: none; + color: black; + text-decoration: underline; + border: none; + } + </style> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/src/webex/pages/payback.tsx b/src/webex/pages/payback.tsx new file mode 100644 index 000000000..7bcc581d8 --- /dev/null +++ b/src/webex/pages/payback.tsx @@ -0,0 +1,100 @@ +/* + This file is part of TALER + (C) 2017 Inria + + 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 <http://www.gnu.org/licenses/> + */ + +/** + * View and edit auditors. + * + * @author Florian Dold + */ + + +import { amountToPretty, getTalerStampDate } from "../../helpers"; +import { + ExchangeRecord, + ExchangeForCurrencyRecord, + DenominationRecord, + AuditorRecord, + CurrencyRecord, + ReserveRecord, + CoinRecord, + PreCoinRecord, + Denomination, + WalletBalance, +} from "../../types"; + +import { ImplicitStateComponent, StateHolder } from "../components"; +import { + getCurrencies, + updateCurrency, + getPaybackReserves, + withdrawPaybackReserve, +} from "../wxApi"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; + +class Payback extends ImplicitStateComponent<any> { + reserves: StateHolder<ReserveRecord[]|null> = this.makeState(null); + constructor() { + super(); + let port = chrome.runtime.connect(); + port.onMessage.addListener((msg: any) => { + if (msg.notify) { + console.log("got notified"); + this.update(); + } + }); + this.update(); + } + + async update() { + let reserves = await getPaybackReserves(); + this.reserves(reserves); + } + + withdrawPayback(pub: string) { + withdrawPaybackReserve(pub); + } + + render(): JSX.Element { + let reserves = this.reserves(); + if (!reserves) { + return <span>loading ...</span>; + } + if (reserves.length == 0) { + return <span>No reserves with payback available.</span>; + } + return ( + <div> + {reserves.map(r => ( + <div> + <h2>Reserve for ${amountToPretty(r.current_amount!)}</h2> + <ul> + <li>Exchange: ${r.exchange_base_url}</li> + </ul> + <button onClick={() => this.withdrawPayback(r.reserve_pub)}>Withdraw again</button> + </div> + ))} + </div> + ); + } +} + +export function main() { + ReactDOM.render(<Payback />, document.getElementById("container")!); +} + +document.addEventListener("DOMContentLoaded", main); diff --git a/src/webex/pages/popup.css b/src/webex/pages/popup.css new file mode 100644 index 000000000..675412c11 --- /dev/null +++ b/src/webex/pages/popup.css @@ -0,0 +1,84 @@ + +/** + * @author Gabor X. Toth + * @author Marcello Stanisci + * @author Florian Dold + */ + +body { + min-height: 20em; + width: 30em; + margin: 0; + padding: 0; + max-height: 800px; + overflow: hidden; +} + +.nav { + background-color: #ddd; + padding: 0.5em 0; +} + +.nav a { + color: black; + padding: 0.5em; + text-decoration: none; +} + +.nav a.active { + background-color: white; + font-weight: bold; +} + + +.container { + overflow-y: scroll; + max-height: 400px; +} + +.abbrev { + text-decoration-style: dotted; +} + +#content { + padding: 1em; +} + + +#wallet-table .amount { + text-align: right; +} + +.hidden { + display: none; +} + +#transactions-table th, +#transactions-table td { + padding: 0.2em 0.5em; +} + +#reserve-create table { + width: 100%; +} + +#reserve-create table td.label { + width: 5em; +} + +#reserve-create table .input input[type="text"] { + width: 100%; +} + +.historyItem { + border: 1px solid black; + border-radius: 10px; + padding-left: 0.5em; + margin: 0.5em; +} + +.historyDate { + font-size: 90%; + margin: 0.3em; + color: slategray; +} diff --git a/src/webex/pages/popup.html b/src/webex/pages/popup.html new file mode 100644 index 000000000..98f24bccc --- /dev/null +++ b/src/webex/pages/popup.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="utf-8"> + + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + <link rel="stylesheet" type="text/css" href="popup.css"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/popup-bundle.js"></script> +</head> + +<body> + <div id="content" style="margin:0;padding:0"></div> +</body> + +</html> diff --git a/src/webex/pages/popup.tsx b/src/webex/pages/popup.tsx new file mode 100644 index 000000000..a806cfef9 --- /dev/null +++ b/src/webex/pages/popup.tsx @@ -0,0 +1,548 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + + +/** + * Popup shown to the user when they click + * the Taler browser action button. + * + * @author Florian Dold + */ + + +"use strict"; + +import { + AmountJson, + Amounts, + WalletBalance, + WalletBalanceEntry +} from "../../types"; +import { HistoryRecord, HistoryLevel } from "../../wallet"; +import { amountToPretty } from "../../helpers"; +import * as i18n from "../../i18n"; + +import { abbrev } from "../renderHtml"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import URI = require("urijs"); + +function onUpdateNotification(f: () => void): () => void { + let port = chrome.runtime.connect({name: "notifications"}); + let listener = (msg: any, port: any) => { + f(); + }; + port.onMessage.addListener(listener); + return () => { + port.onMessage.removeListener(listener); + } +} + + +class Router extends React.Component<any,any> { + static setRoute(s: string): void { + window.location.hash = s; + } + + static getRoute(): string { + // Omit the '#' at the beginning + return window.location.hash.substring(1); + } + + static onRoute(f: any): () => void { + Router.routeHandlers.push(f); + return () => { + let i = Router.routeHandlers.indexOf(f); + this.routeHandlers = this.routeHandlers.splice(i, 1); + } + } + + static routeHandlers: any[] = []; + + componentWillMount() { + console.log("router mounted"); + window.onhashchange = () => { + this.setState({}); + for (let f of Router.routeHandlers) { + f(); + } + } + } + + componentWillUnmount() { + console.log("router unmounted"); + } + + + render(): JSX.Element { + let route = window.location.hash.substring(1); + console.log("rendering route", route); + let defaultChild: React.ReactChild|null = null; + let foundChild: React.ReactChild|null = null; + React.Children.forEach(this.props.children, (child) => { + let childProps: any = (child as any).props; + if (!childProps) { + return; + } + if (childProps["default"]) { + defaultChild = child; + } + if (childProps["route"] == route) { + foundChild = child; + } + }) + let child: React.ReactChild | null = foundChild || defaultChild; + if (!child) { + throw Error("unknown route"); + } + Router.setRoute((child as any).props["route"]); + return <div>{child}</div>; + } +} + + +interface TabProps { + target: string; + children?: React.ReactNode; +} + +function Tab(props: TabProps) { + let cssClass = ""; + if (props.target == Router.getRoute()) { + cssClass = "active"; + } + let onClick = (e: React.MouseEvent<HTMLAnchorElement>) => { + Router.setRoute(props.target); + e.preventDefault(); + }; + return ( + <a onClick={onClick} href={props.target} className={cssClass}> + {props.children} + </a> + ); +} + + +class WalletNavBar extends React.Component<any,any> { + cancelSubscription: any; + + componentWillMount() { + this.cancelSubscription = Router.onRoute(() => { + this.setState({}); + }); + } + + componentWillUnmount() { + if (this.cancelSubscription) { + this.cancelSubscription(); + } + } + + render() { + console.log("rendering nav bar"); + return ( + <div className="nav" id="header"> + <Tab target="/balance"> + {i18n.str`Balance`} + </Tab> + <Tab target="/history"> + {i18n.str`History`} + </Tab> + <Tab target="/debug"> + {i18n.str`Debug`} + </Tab> + </div>); + } +} + + +function ExtensionLink(props: any) { + let onClick = (e: React.MouseEvent<HTMLAnchorElement>) => { + chrome.tabs.create({ + "url": chrome.extension.getURL(props.target) + }); + e.preventDefault(); + }; + return ( + <a onClick={onClick} href={props.target}> + {props.children} + </a>) +} + + +export function bigAmount(amount: AmountJson): JSX.Element { + let v = amount.value + amount.fraction / Amounts.fractionalBase; + return ( + <span> + <span style={{fontSize: "300%"}}>{v}</span> + {" "} + <span>{amount.currency}</span> + </span> + ); +} + +class WalletBalanceView extends React.Component<any, any> { + balance: WalletBalance; + gotError = false; + canceler: (() => void) | undefined = undefined; + unmount = false; + + componentWillMount() { + this.canceler = onUpdateNotification(() => this.updateBalance()); + this.updateBalance(); + } + + componentWillUnmount() { + console.log("component WalletBalanceView will unmount"); + if (this.canceler) { + this.canceler(); + } + this.unmount = true; + } + + updateBalance() { + chrome.runtime.sendMessage({type: "balances"}, (resp) => { + if (this.unmount) { + return; + } + if (resp.error) { + this.gotError = true; + console.error("could not retrieve balances", resp); + this.setState({}); + return; + } + this.gotError = false; + console.log("got wallet", resp); + this.balance = resp; + this.setState({}); + }); + } + + renderEmpty(): JSX.Element { + let helpLink = ( + <ExtensionLink target="/src/pages/help/empty-wallet.html"> + {i18n.str`help`} + </ExtensionLink> + ); + return ( + <div> + <i18n.Translate wrap="p"> + You have no balance to show. Need some + {" "}<span>{helpLink}</span>{" "} + getting started? + </i18n.Translate> + </div> + ); + } + + formatPending(entry: WalletBalanceEntry): JSX.Element { + let incoming: JSX.Element | undefined; + let payment: JSX.Element | undefined; + + console.log("available: ", entry.pendingIncoming ? amountToPretty(entry.available) : null); + console.log("incoming: ", entry.pendingIncoming ? amountToPretty(entry.pendingIncoming) : null); + + if (Amounts.isNonZero(entry.pendingIncoming)) { + incoming = ( + <i18n.Translate wrap="span"> + <span style={{color: "darkgreen"}}> + {"+"} + {amountToPretty(entry.pendingIncoming)} + </span> + {" "} + incoming + </i18n.Translate> + ); + } + + if (Amounts.isNonZero(entry.pendingPayment)) { + payment = ( + <i18n.Translate wrap="span"> + <span style={{color: "darkblue"}}> + {amountToPretty(entry.pendingPayment)} + </span> + {" "} + being spent + </i18n.Translate> + ); + } + + let l = [incoming, payment].filter((x) => x !== undefined); + if (l.length == 0) { + return <span />; + } + + if (l.length == 1) { + return <span>({l})</span> + } + return <span>({l[0]}, {l[1]})</span>; + + } + + render(): JSX.Element { + let wallet = this.balance; + if (this.gotError) { + return i18n.str`Error: could not retrieve balance information.`; + } + if (!wallet) { + return <span></span>; + } + console.log(wallet); + let paybackAvailable = false; + let listing = Object.keys(wallet).map((key) => { + let entry: WalletBalanceEntry = wallet[key]; + if (entry.paybackAmount.value != 0 || entry.paybackAmount.fraction != 0) { + paybackAvailable = true; + } + return ( + <p> + {bigAmount(entry.available)} + {" "} + {this.formatPending(entry)} + </p> + ); + }); + let link = chrome.extension.getURL("/src/pages/auditors.html"); + let linkElem = <a className="actionLink" href={link} target="_blank">Trusted Auditors and Exchanges</a>; + let paybackLink = chrome.extension.getURL("/src/pages/payback.html"); + let paybackLinkElem = <a className="actionLink" href={link} target="_blank">Trusted Auditors and Exchanges</a>; + return ( + <div> + {listing.length > 0 ? listing : this.renderEmpty()} + {paybackAvailable && paybackLinkElem} + {linkElem} + </div> + ); + } +} + + +function formatHistoryItem(historyItem: HistoryRecord) { + const d = historyItem.detail; + const t = historyItem.timestamp; + console.log("hist item", historyItem); + switch (historyItem.type) { + case "create-reserve": + return ( + <i18n.Translate wrap="p"> + Bank requested reserve (<span>{abbrev(d.reservePub)}</span>) for <span>{amountToPretty(d.requestedAmount)}</span>. + </i18n.Translate> + ); + case "confirm-reserve": { + // FIXME: eventually remove compat fix + let exchange = d.exchangeBaseUrl ? (new URI(d.exchangeBaseUrl)).host() : "??"; + let pub = abbrev(d.reservePub); + return ( + <i18n.Translate wrap="p"> + Started to withdraw + {" "}{amountToPretty(d.requestedAmount)}{" "} + from <span>{exchange}</span> (<span>{pub}</span>). + </i18n.Translate> + ); + } + case "offer-contract": { + let link = chrome.extension.getURL("view-contract.html"); + let linkElem = <a href={link}>{abbrev(d.contractHash)}</a>; + let merchantElem = <em>{abbrev(d.merchantName, 15)}</em>; + return ( + <i18n.Translate wrap="p"> + Merchant <em>{abbrev(d.merchantName, 15)}</em> offered contract <a href={link}>{abbrev(d.contractHash)}</a>; + </i18n.Translate> + ); + } + case "depleted-reserve": { + let exchange = d.exchangeBaseUrl ? (new URI(d.exchangeBaseUrl)).host() : "??"; + let amount = amountToPretty(d.requestedAmount); + let pub = abbrev(d.reservePub); + return ( + <i18n.Translate wrap="p"> + Withdrew <span>{amount}</span> from <span>{exchange}</span> (<span>{pub}</span>). + </i18n.Translate> + ); + } + case "pay": { + let url = d.fulfillmentUrl; + let merchantElem = <em>{abbrev(d.merchantName, 15)}</em>; + let fulfillmentLinkElem = <a href={url} onClick={openTab(url)}>view product</a>; + return ( + <i18n.Translate wrap="p"> + Paid <span>{amountToPretty(d.amount)}</span> to merchant <span>{merchantElem}</span>. (<span>{fulfillmentLinkElem}</span>) + </i18n.Translate> + ); + } + default: + return (<p>{i18n.str`Unknown event (${historyItem.type})`}</p>); + } +} + + +class WalletHistory extends React.Component<any, any> { + myHistory: any[]; + gotError = false; + unmounted = false; + + componentWillMount() { + this.update(); + onUpdateNotification(() => this.update()); + } + + componentWillUnmount() { + console.log("history component unmounted"); + this.unmounted = true; + } + + update() { + chrome.runtime.sendMessage({type: "get-history"}, (resp) => { + if (this.unmounted) { + return; + } + console.log("got history response"); + if (resp.error) { + this.gotError = true; + console.error("could not retrieve history", resp); + this.setState({}); + return; + } + this.gotError = false; + console.log("got history", resp.history); + this.myHistory = resp.history; + this.setState({}); + }); + } + + render(): JSX.Element { + console.log("rendering history"); + let history: HistoryRecord[] = this.myHistory; + if (this.gotError) { + return i18n.str`Error: could not retrieve event history`; + } + + if (!history) { + // We're not ready yet + return <span />; + } + + let subjectMemo: {[s: string]: boolean} = {}; + let listing: any[] = []; + for (let record of history.reverse()) { + if (record.subjectId && subjectMemo[record.subjectId]) { + continue; + } + if (record.level != undefined && record.level < HistoryLevel.User) { + continue; + } + subjectMemo[record.subjectId as string] = true; + + let item = ( + <div className="historyItem"> + <div className="historyDate"> + {(new Date(record.timestamp)).toString()} + </div> + {formatHistoryItem(record)} + </div> + ); + + listing.push(item); + } + + if (listing.length > 0) { + return <div className="container">{listing}</div>; + } + return <p>{i18n.str`Your wallet has no events recorded.`}</p> + } + +} + + +function reload() { + try { + chrome.runtime.reload(); + window.close(); + } catch (e) { + // Functionality missing in firefox, ignore! + } +} + +function confirmReset() { + if (confirm("Do you want to IRREVOCABLY DESTROY everything inside your" + + " wallet and LOSE ALL YOUR COINS?")) { + chrome.runtime.sendMessage({type: "reset"}); + window.close(); + } +} + + +function WalletDebug(props: any) { + return (<div> + <p>Debug tools:</p> + <button onClick={openExtensionPage("/src/pages/popup.html")}> + wallet tab + </button> + <button onClick={openExtensionPage("/src/pages/show-db.html")}> + show db + </button> + <button onClick={openExtensionPage("/src/pages/tree.html")}> + show tree + </button> + <button onClick={openExtensionPage("/src/pages/logs.html")}> + show logs + </button> + <br /> + <button onClick={confirmReset}> + reset + </button> + <button onClick={reload}> + reload chrome extension + </button> + </div>); +} + + +function openExtensionPage(page: string) { + return function() { + chrome.tabs.create({ + "url": chrome.extension.getURL(page) + }); + } +} + + +function openTab(page: string) { + return function() { + chrome.tabs.create({ + "url": page + }); + } +} + + +let el = ( + <div> + <WalletNavBar /> + <div style={{margin: "1em"}}> + <Router> + <WalletBalanceView route="/balance" default/> + <WalletHistory route="/history"/> + <WalletDebug route="/debug"/> + </Router> + </div> + </div> +); + +document.addEventListener("DOMContentLoaded", () => { + ReactDOM.render(el, document.getElementById("content")!); +}) diff --git a/src/webex/pages/show-db.html b/src/webex/pages/show-db.html new file mode 100644 index 000000000..215c726d9 --- /dev/null +++ b/src/webex/pages/show-db.html @@ -0,0 +1,18 @@ +<!doctype html> +<html> + <head> + <meta charset="UTF-8"> + <title>Taler Wallet: Reserve Created</title> + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + <link rel="icon" href="/img/icon.png"> + <script src="/dist/page-common.js"></script> + <script src="/dist/show-db-bundle.js"></script> + </head> + <body> + <h1>DB Dump</h1> + <input type="file" id="fileInput" style="display:none"> + <button id="import">Import Dump</button> + <button id="download">Download Dump</button> + <pre id="dump"></pre> + </body> +</html> diff --git a/src/webex/pages/show-db.ts b/src/webex/pages/show-db.ts new file mode 100644 index 000000000..d95951385 --- /dev/null +++ b/src/webex/pages/show-db.ts @@ -0,0 +1,94 @@ +/* + This file is part of TALER + (C) 2015 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 <http://www.gnu.org/licenses/> + */ + + +/** + * Wallet database dump for debugging. + * + * @author Florian Dold + */ + +function replacer(match: string, pIndent: string, pKey: string, pVal: string, + pEnd: string) { + const key = "<span class=json-key>"; + const val = "<span class=json-value>"; + const str = "<span class=json-string>"; + let r = pIndent || ""; + if (pKey) { + r = r + key + '"' + pKey.replace(/[": ]/g, "") + '":</span> '; + } + if (pVal) { + r = r + (pVal[0] === '"' ? str : val) + pVal + "</span>"; + } + return r + (pEnd || ""); +} + + +function prettyPrint(obj: any) { + const jsonLine = /^( *)("[\w]+": )?("[^"]*"|[\w.+-]*)?([,[{])?$/mg; + return JSON.stringify(obj, null as any, 3) + .replace(/&/g, "&").replace(/\\"/g, """) + .replace(/</g, "<").replace(/>/g, ">") + .replace(jsonLine, replacer); +} + + +document.addEventListener("DOMContentLoaded", () => { + chrome.runtime.sendMessage({type: "dump-db"}, (resp) => { + const el = document.getElementById("dump"); + if (!el) { + throw Error(); + } + el.innerHTML = prettyPrint(resp); + + document.getElementById("download")!.addEventListener("click", (evt) => { + console.log("creating download"); + const element = document.createElement("a"); + element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(JSON.stringify(resp))); + element.setAttribute("download", "wallet-dump.txt"); + element.style.display = "none"; + document.body.appendChild(element); + element.click(); + }); + + }); + + + const fileInput = document.getElementById("fileInput")! as HTMLInputElement; + fileInput.onchange = (evt) => { + if (!fileInput.files || fileInput.files.length !== 1) { + alert("please select exactly one file to import"); + return; + } + const file = fileInput.files[0]; + const fr = new FileReader(); + fr.onload = (e: any) => { + console.log("got file"); + const dump = JSON.parse(e.target.result); + console.log("parsed contents", dump); + chrome.runtime.sendMessage({ type: "import-db", detail: { dump } }, (resp) => { + alert("loaded"); + }); + }; + console.log("reading file", file); + fr.readAsText(file); + }; + + document.getElementById("import")!.addEventListener("click", (evt) => { + fileInput.click(); + evt.preventDefault(); + }); +}); diff --git a/src/webex/pages/tree.html b/src/webex/pages/tree.html new file mode 100644 index 000000000..0c0a368b3 --- /dev/null +++ b/src/webex/pages/tree.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <title>Taler Wallet: Tree View</title> + + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + + <link rel="icon" href="/img/icon.png"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/tree-bundle.js"></script> + + <style> + .tree-item { + margin: 2em; + border-radius: 5px; + border: 1px solid gray; + padding: 1em; + } + </style> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/src/webex/pages/tree.tsx b/src/webex/pages/tree.tsx new file mode 100644 index 000000000..ddf8f2dbc --- /dev/null +++ b/src/webex/pages/tree.tsx @@ -0,0 +1,437 @@ +/* + This file is part of TALER + (C) 2016 Inria + + 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 <http://www.gnu.org/licenses/> + */ + +/** + * Show contents of the wallet as a tree. + * + * @author Florian Dold + */ + + +import { amountToPretty, getTalerStampDate } from "../../helpers"; +import { + CoinRecord, + CoinStatus, + Denomination, + DenominationRecord, + ExchangeRecord, + PreCoinRecord, + ReserveRecord, +} from "../../types"; + +import { ImplicitStateComponent, StateHolder } from "../components"; +import { + getReserves, getExchanges, getCoins, getPreCoins, + refresh, getDenoms, payback, +} from "../wxApi"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; + +interface ReserveViewProps { + reserve: ReserveRecord; +} + +class ReserveView extends React.Component<ReserveViewProps, void> { + render(): JSX.Element { + let r: ReserveRecord = this.props.reserve; + return ( + <div className="tree-item"> + <ul> + <li>Key: {r.reserve_pub}</li> + <li>Created: {(new Date(r.created * 1000).toString())}</li> + <li>Current: {r.current_amount ? amountToPretty(r.current_amount!) : "null"}</li> + <li>Requested: {amountToPretty(r.requested_amount)}</li> + <li>Confirmed: {r.confirmed}</li> + </ul> + </div> + ); + } +} + +interface ReserveListProps { + exchangeBaseUrl: string; +} + +interface ToggleProps { + expanded: StateHolder<boolean>; +} + +class Toggle extends ImplicitStateComponent<ToggleProps> { + renderButton() { + let show = () => { + this.props.expanded(true); + this.setState({}); + }; + let hide = () => { + this.props.expanded(false); + this.setState({}); + }; + if (this.props.expanded()) { + return <button onClick={hide}>hide</button>; + } + return <button onClick={show}>show</button>; + + } + render() { + return ( + <div style={{display: "inline"}}> + {this.renderButton()} + {this.props.expanded() ? this.props.children : []} + </div>); + } +} + + +interface CoinViewProps { + coin: CoinRecord; +} + +interface RefreshDialogProps { + coin: CoinRecord; +} + +class RefreshDialog extends ImplicitStateComponent<RefreshDialogProps> { + refreshRequested = this.makeState<boolean>(false); + render(): JSX.Element { + if (!this.refreshRequested()) { + return ( + <div style={{display: "inline"}}> + <button onClick={() => this.refreshRequested(true)}>refresh</button> + </div> + ); + } + return ( + <div> + Refresh amount: <input type="text" size={10} /> + <button onClick={() => refresh(this.props.coin.coinPub)}>ok</button> + <button onClick={() => this.refreshRequested(false)}>cancel</button> + </div> + ); + } +} + +class CoinView extends React.Component<CoinViewProps, void> { + render() { + let c = this.props.coin; + return ( + <div className="tree-item"> + <ul> + <li>Key: {c.coinPub}</li> + <li>Current amount: {amountToPretty(c.currentAmount)}</li> + <li>Denomination: <ExpanderText text={c.denomPub} /></li> + <li>Suspended: {(c.suspended || false).toString()}</li> + <li>Status: {CoinStatus[c.status]}</li> + <li><RefreshDialog coin={c} /></li> + <li><button onClick={() => payback(c.coinPub)}>Payback</button></li> + </ul> + </div> + ); + } +} + + + +interface PreCoinViewProps { + precoin: PreCoinRecord; +} + +class PreCoinView extends React.Component<PreCoinViewProps, void> { + render() { + let c = this.props.precoin; + return ( + <div className="tree-item"> + <ul> + <li>Key: {c.coinPub}</li> + </ul> + </div> + ); + } +} + +interface CoinListProps { + exchangeBaseUrl: string; +} + +class CoinList extends ImplicitStateComponent<CoinListProps> { + coins = this.makeState<CoinRecord[] | null>(null); + expanded = this.makeState<boolean>(false); + + constructor(props: CoinListProps) { + super(props); + this.update(props); + } + + async update(props: CoinListProps) { + let coins = await getCoins(props.exchangeBaseUrl); + this.coins(coins); + } + + componentWillReceiveProps(newProps: CoinListProps) { + this.update(newProps); + } + + render(): JSX.Element { + if (!this.coins()) { + return <div>...</div>; + } + return ( + <div className="tree-item"> + Coins ({this.coins() !.length.toString()}) + {" "} + <Toggle expanded={this.expanded}> + {this.coins() !.map((c) => <CoinView coin={c} />)} + </Toggle> + </div> + ); + } +} + + +interface PreCoinListProps { + exchangeBaseUrl: string; +} + +class PreCoinList extends ImplicitStateComponent<PreCoinListProps> { + precoins = this.makeState<PreCoinRecord[] | null>(null); + expanded = this.makeState<boolean>(false); + + constructor(props: PreCoinListProps) { + super(props); + this.update(); + } + + async update() { + let precoins = await getPreCoins(this.props.exchangeBaseUrl); + this.precoins(precoins); + } + + render(): JSX.Element { + if (!this.precoins()) { + return <div>...</div>; + } + return ( + <div className="tree-item"> + Planchets ({this.precoins() !.length.toString()}) + {" "} + <Toggle expanded={this.expanded}> + {this.precoins() !.map((c) => <PreCoinView precoin={c} />)} + </Toggle> + </div> + ); + } +} + +interface DenominationListProps { + exchange: ExchangeRecord; +} + +interface ExpanderTextProps { + text: string; +} + +class ExpanderText extends ImplicitStateComponent<ExpanderTextProps> { + expanded = this.makeState<boolean>(false); + textArea: any = undefined; + + componentDidUpdate() { + if (this.expanded() && this.textArea) { + this.textArea.focus(); + this.textArea.scrollTop = 0; + } + } + + render(): JSX.Element { + if (!this.expanded()) { + return ( + <span onClick={() => { this.expanded(true); }}> + {(this.props.text.length <= 10) + ? this.props.text + : ( + <span> + {this.props.text.substring(0,10)} + <span style={{textDecoration: "underline"}}>...</span> + </span> + ) + } + </span> + ); + } + return ( + <textarea + readOnly + style={{display: "block"}} + onBlur={() => this.expanded(false)} + ref={(e) => this.textArea = e}> + {this.props.text} + </textarea> + ); + } +} + +class DenominationList extends ImplicitStateComponent<DenominationListProps> { + expanded = this.makeState<boolean>(false); + denoms = this.makeState<undefined|DenominationRecord[]>(undefined); + + constructor(props: DenominationListProps) { + super(props); + this.update(); + } + + async update() { + let d = await getDenoms(this.props.exchange.baseUrl); + this.denoms(d); + } + + renderDenom(d: DenominationRecord) { + return ( + <div className="tree-item"> + <ul> + <li>Offered: {d.isOffered ? "yes" : "no"}</li> + <li>Value: {amountToPretty(d.value)}</li> + <li>Withdraw fee: {amountToPretty(d.feeWithdraw)}</li> + <li>Refresh fee: {amountToPretty(d.feeRefresh)}</li> + <li>Deposit fee: {amountToPretty(d.feeDeposit)}</li> + <li>Refund fee: {amountToPretty(d.feeRefund)}</li> + <li>Start: {getTalerStampDate(d.stampStart)!.toString()}</li> + <li>Withdraw expiration: {getTalerStampDate(d.stampExpireWithdraw)!.toString()}</li> + <li>Legal expiration: {getTalerStampDate(d.stampExpireLegal)!.toString()}</li> + <li>Deposit expiration: {getTalerStampDate(d.stampExpireDeposit)!.toString()}</li> + <li>Denom pub: <ExpanderText text={d.denomPub} /></li> + </ul> + </div> + ); + } + + render(): JSX.Element { + let denoms = this.denoms() + if (!denoms) { + return ( + <div className="tree-item"> + Denominations (...) + {" "} + <Toggle expanded={this.expanded}> + ... + </Toggle> + </div> + ); + } + return ( + <div className="tree-item"> + Denominations ({denoms.length.toString()}) + {" "} + <Toggle expanded={this.expanded}> + {denoms.map((d) => this.renderDenom(d))} + </Toggle> + </div> + ); + } +} + +class ReserveList extends ImplicitStateComponent<ReserveListProps> { + reserves = this.makeState<ReserveRecord[] | null>(null); + expanded = this.makeState<boolean>(false); + + constructor(props: ReserveListProps) { + super(props); + this.update(); + } + + async update() { + let reserves = await getReserves(this.props.exchangeBaseUrl); + this.reserves(reserves); + } + + render(): JSX.Element { + if (!this.reserves()) { + return <div>...</div>; + } + return ( + <div className="tree-item"> + Reserves ({this.reserves() !.length.toString()}) + {" "} + <Toggle expanded={this.expanded}> + {this.reserves() !.map((r) => <ReserveView reserve={r} />)} + </Toggle> + </div> + ); + } +} + +interface ExchangeProps { + exchange: ExchangeRecord; +} + +class ExchangeView extends React.Component<ExchangeProps, void> { + render(): JSX.Element { + let e = this.props.exchange; + return ( + <div className="tree-item"> + <ul> + <li>Exchange Base Url: {this.props.exchange.baseUrl}</li> + <li>Master public key: <ExpanderText text={this.props.exchange.masterPublicKey} /></li> + </ul> + <DenominationList exchange={e} /> + <ReserveList exchangeBaseUrl={this.props.exchange.baseUrl} /> + <CoinList exchangeBaseUrl={this.props.exchange.baseUrl} /> + <PreCoinList exchangeBaseUrl={this.props.exchange.baseUrl} /> + </div> + ); + } +} + +interface ExchangesListState { + exchanges?: ExchangeRecord[]; +} + +class ExchangesList extends React.Component<any, ExchangesListState> { + constructor() { + super(); + let port = chrome.runtime.connect(); + port.onMessage.addListener((msg: any) => { + if (msg.notify) { + console.log("got notified"); + this.update(); + } + }); + this.update(); + this.state = {} as any; + } + + async update() { + let exchanges = await getExchanges(); + console.log("exchanges: ", exchanges); + this.setState({ exchanges }); + } + + render(): JSX.Element { + let exchanges = this.state.exchanges; + if (!exchanges) { + return <span>...</span>; + } + return ( + <div className="tree-item"> + Exchanges ({exchanges.length.toString()}): + {exchanges.map(e => <ExchangeView exchange={e} />)} + </div> + ); + } +} + +export function main() { + ReactDOM.render(<ExchangesList />, document.getElementById("container")!); +} + +document.addEventListener("DOMContentLoaded", main); |