refactor reserve creation dialog

This commit is contained in:
Florian Dold 2016-10-07 17:10:22 +02:00
parent cb424d3699
commit 7f95c83f2f
3 changed files with 181 additions and 222 deletions

View File

@ -24,7 +24,6 @@
/// <reference path="../lib/decl/preact.d.ts" /> /// <reference path="../lib/decl/preact.d.ts" />
import {substituteFulfillmentUrl} from "../lib/wallet/helpers"; import {substituteFulfillmentUrl} from "../lib/wallet/helpers";
import m from "mithril";
import {Contract, AmountJson} from "../lib/wallet/types"; import {Contract, AmountJson} from "../lib/wallet/types";
import {renderContract, prettyAmount} from "../lib/wallet/renderHtml"; import {renderContract, prettyAmount} from "../lib/wallet/renderHtml";
"use strict"; "use strict";
@ -38,8 +37,6 @@ interface DetailProps {
contract: Contract; contract: Contract;
} }
let h = preact.h;
class Details extends preact.Component<DetailProps, DetailState> { class Details extends preact.Component<DetailProps, DetailState> {
constructor() { constructor() {
@ -60,18 +57,20 @@ class Details extends preact.Component<DetailProps, DetailState> {
</div> </div>
); );
} else { } else {
return h("div", {}, return (
h("button", { <div>
className: "linky", <button className="linky"
onClick: () => { onClick={() => this.setState({collapsed: true})}>
this.setState({collapsed: true}); show less details
} </button>
}, "show less details"), <div>
h("div", {}, Accepted exchanges:
"Accepted exchanges:", <ul>
h("ul", {}, {props.contract.exchanges.map(
...props.contract.exchanges.map( e => <li>{`${e.url}: ${e.master_pub}`}</li>)}
e => h("li", {}, `${e.url}: ${e.master_pub}`))))); </ul>
</div>
</div>);
} }
} }
} }
@ -157,19 +156,17 @@ class ContractPrompt extends preact.Component<ContractPromptProps, ContractPromp
render(props: ContractPromptProps, state: ContractPromptState) { render(props: ContractPromptProps, state: ContractPromptState) {
let c = props.offer.contract; let c = props.offer.contract;
return h("div", {}, return (
renderContract(c), <div>
h("button", {renderContract(c)}
{ <button onClick={() => this.doPayment()}
onClick: () => this.doPayment(), disabled={state.payDisabled}
disabled: state.payDisabled, className="accept">
"className": "accept" Confirm payment
}, </button>
i18n`Confirm Payment`), (state.error ? <p className="errorbox">{state.error}</p> : <p />)
(state.error ? h("p", <Details contract={c} />
{className: "errorbox"}, </div>
state.error) : h("p", "")),
h(Details, {contract: c})
); );
} }
} }
@ -182,7 +179,6 @@ export function main() {
console.dir(offer); console.dir(offer);
let contract = offer.contract; let contract = offer.contract;
preact.render(<ContractPrompt offer={offer}/>, document.getElementById(
let prompt = h(ContractPrompt, {offer}); "contract")!);
preact.render(prompt, document.getElementById("contract")!);
} }

View File

@ -18,6 +18,7 @@
<script src="../lib/vendor/system-csp-production.src.js"></script> <script src="../lib/vendor/system-csp-production.src.js"></script>
<script src="../lib/module-trampoline.js"></script> <script src="../lib/module-trampoline.js"></script>
<style> <style>
#main { #main {
border: solid 1px black; border: solid 1px black;
@ -47,6 +48,17 @@
cursor:pointer; cursor:pointer;
} }
button.accept:disabled {
background-color: #dedbe8;
border: 1px solid white;
border-radius: 5px;
margin: 1em 0;
padding: 0.5em;
font-weight: bold;
color: #2C2C2C;
}
input.url { input.url {
width: 25em; width: 25em;
} }

View File

@ -32,35 +32,37 @@ import {getReserveCreationInfo} from "../lib/wallet/wxApi";
let h = preact.h; let h = preact.h;
/** function delay<T>(delayMs: number, value: T): Promise<T> {
* Execute something after a delay, with the possibility return new Promise<T>((resolve, reject) => {
* to reset the delay. setTimeout(() => resolve(value), delayMs);
*/ });
class DelayTimer { }
ms: number;
f: () => void;
timerId: number|undefined = undefined;
constructor(ms: number, f: () => void) { class EventTrigger {
this.f = f; triggerResolve: any;
this.ms = ms; triggerPromise: Promise<boolean>;
constructor() {
this.reset();
} }
bump() { private reset() {
this.stop(); this.triggerPromise = new Promise<boolean>((resolve, reject) => {
const handler = () => { this.triggerResolve = resolve;
this.f(); });
};
this.timerId = window.setTimeout(handler, this.ms);
} }
stop() { trigger() {
if (this.timerId != undefined) { this.triggerResolve(false);
window.clearTimeout(this.timerId); this.reset();
} }
async wait(delayMs: number): Promise<boolean> {
return await Promise.race([this.triggerPromise, delay(delayMs, true)]);
} }
} }
interface StateHolder<T> { interface StateHolder<T> {
(): T; (): T;
(newState: T): void; (newState: T): void;
@ -85,7 +87,11 @@ abstract class ImplicitStateComponent<PropType> extends preact.Component<PropTyp
} }
function renderReserveCreationDetails(rci: ReserveCreationInfo) { 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 denoms = rci.selectedDenoms;
let countByPub: {[s: string]: number} = {}; let countByPub: {[s: string]: number} = {};
@ -153,6 +159,17 @@ function getSuggestedExchange(currency: string): Promise<string> {
return Promise.resolve(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>Withdraw fees: {amountToPretty(totalCost)}</p>;
}
return <p />;
}
interface ExchangeSelectionProps { interface ExchangeSelectionProps {
suggestedExchangeUrl: string; suggestedExchangeUrl: string;
amount: AmountJson; amount: AmountJson;
@ -162,84 +179,77 @@ interface ExchangeSelectionProps {
class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> { class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> {
complexViewRequested: StateHolder<boolean> = this.makeState(false);
statusString: StateHolder<string|null> = this.makeState(null); statusString: StateHolder<string|null> = this.makeState(null);
reserveCreationInfo: StateHolder<ReserveCreationInfo|null> = this.makeState( reserveCreationInfo: StateHolder<ReserveCreationInfo|null> = this.makeState(
null); null);
url: StateHolder<string|null> = this.makeState(null); url: StateHolder<string|null> = this.makeState(null);
detailCollapsed: StateHolder<boolean> = this.makeState(true); detailCollapsed: StateHolder<boolean> = this.makeState(true);
private timer: DelayTimer; updateEvent = new EventTrigger();
isValidExchange: boolean;
constructor(props: ExchangeSelectionProps) { constructor(props: ExchangeSelectionProps) {
super(props); super(props);
this.timer = new DelayTimer(800, () => this.update()); this.onUrlChanged(props.suggestedExchangeUrl || null);
this.url(props.suggestedExchangeUrl || null); }
this.update();
renderAdvanced(): JSX.Element {
if (this.detailCollapsed()) {
return (
<button className="linky"
onClick={() => this.detailCollapsed(false)}>
view fee structure / select different exchange provider
</button>
);
}
return (
<div>
<h2>Provider Selection</h2>
<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)}/>
<br />
{this.renderStatus()}
<h2>Detailed Fee Structure</h2>
{renderReserveCreationDetails(this.reserveCreationInfo())}
</div>)
}
renderFee() {
if (!this.reserveCreationInfo()) {
return "??";
}
let rci = this.reserveCreationInfo()!;
let totalCost = Amounts.add(rci.overhead, rci.withdrawFee).amount;
return `${amountToPretty(totalCost)}`;
} }
render(props: ExchangeSelectionProps): JSX.Element { render(props: ExchangeSelectionProps): JSX.Element {
console.log("props", props);
let header = (
<p>
{"You are about to withdraw "}
<strong>{amountToPretty(props.amount)}</strong>
{" from your bank account into your wallet."}
</p>
);
if (this.complexViewRequested() || !props.suggestedExchangeUrl) {
return (
<div>
{header}
{this.viewComplex()}
</div>);
}
return ( return (
<div> <div>
{header} <p>
{this.viewSimple()} {"You are about to withdraw "}
</div>); <strong>{amountToPretty(props.amount)}</strong>
} {" from your bank account into your wallet."}
</p>
<p>
viewSimple() { The exchange provider will charge
let advancedButton = ( {" "}
<button className="linky" {this.renderFee()}
onClick={() => this.complexViewRequested(true)}> {" "}
advanced options in fees.
</button> </p>
<button className="accept"
disabled={this.reserveCreationInfo() == null}
onClick={() => this.confirmReserve()}>
Accept fees and withdraw
</button>
<br/>
{this.renderAdvanced()}
</div>
); );
if (this.statusString()) {
return (
<div>
<p>Error: {this.statusString()}</p>
{advancedButton}
</div>
);
}
else if (this.reserveCreationInfo() != undefined) {
let {overhead, withdrawFee} = this.reserveCreationInfo()!;
let totalCost = Amounts.add(overhead, withdrawFee).amount;
return (
<div>
<p>{`Withdraw fees: ${amountToPretty(totalCost)}`}</p>
<button className="accept"
onClick={() => this.confirmReserve()}>
Accept fees and withdraw
</button>
<span className="spacer"/>
{advancedButton}
</div>
);
} else {
return <p>Please wait...</p>
}
} }
@ -250,53 +260,41 @@ class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> {
this.props.callback_url); this.props.callback_url);
} }
/**
* Do an update of the reserve creation info, without any debouncing.
*/
async forceReserveUpdate() {
this.reserveCreationInfo(null);
if (!this.url()) {
this.statusString(i18n`Error: URL is empty`);
return;
}
update() { this.statusString(null);
this.timer.stop(); let parsedUrl = URI(this.url()!);
const doUpdate = () => { if (parsedUrl.is("relative")) {
this.reserveCreationInfo(null); this.statusString(i18n`Error: URL may not be relative`);
if (!this.url()) { return;
this.statusString = i18n`Error: URL is empty`; }
m.redraw(true);
return; try {
let r = await getReserveCreationInfo(this.url()!,
this.props.amount);
console.log("get exchange info resolved");
this.reserveCreationInfo(r);
console.dir(r);
} catch (e) {
console.log("get exchange info rejected");
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})`);
} }
this.statusString(null); }
let parsedUrl = URI(this.url()!);
if (parsedUrl.is("relative")) {
this.statusString = i18n`Error: URL may not be relative`;
this.forceUpdate();
return;
}
this.forceUpdate();
console.log("doing get exchange info");
getReserveCreationInfo(this.url()!, this.props.amount)
.then((r: ReserveCreationInfo) => {
console.log("get exchange info resolved");
this.isValidExchange = true;
this.reserveCreationInfo(r);
console.dir(r);
})
.catch((e) => {
console.log("get exchange info rejected");
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})`);
}
});
};
doUpdate();
console.log("got update", this.url());
} }
reset() { reset() {
this.isValidExchange = false;
this.statusString(null); this.statusString(null);
this.reserveCreationInfo(null); this.reserveCreationInfo(null);
} }
@ -338,75 +336,28 @@ class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> {
chrome.runtime.sendMessage({type: 'create-reserve', detail: d}, cb); chrome.runtime.sendMessage({type: 'create-reserve', detail: d}, cb);
} }
onUrlChanged(url: string) { async onUrlChanged(url: string|null) {
this.reset(); this.reset();
this.url(url); this.url(url);
this.timer.bump(); if (url == undefined) {
return;
}
this.updateEvent.trigger();
let waited = await this.updateEvent.wait(200);
if (waited) {
// Run the actual update if nobody else preempted us.
this.forceReserveUpdate();
this.forceUpdate();
}
} }
viewComplex() { renderStatus(): any {
function *f(): IterableIterator<any> { if (this.statusString()) {
if (this.reserveCreationInfo()) { return <p><strong style="color: red;">{this.statusString()}</strong></p>;
let {overhead, withdrawFee} = this.reserveCreationInfo()!; } else if (!this.reserveCreationInfo()) {
let totalCost = Amounts.add(overhead, withdrawFee).amount; return <p>Checking URL, please wait ...</p>;
yield <p>Withdraw fees: {amountToPretty(totalCost)}</p>;
}
yield (
<button className="accept" disabled={!this.isValidExchange}
onClick={() => this.confirmReserve()}>
Accept fees and withdraw
</button>
);
yield <span className="spacer"/>;
yield (
<button className="linky"
onClick={() => this.complexViewRequested(true)}/>
);
yield <br/>;
yield (
<input className="url" type="text" spellCheck={false}
value={this.url()!}
onInput={(e) => this.onUrlChanged((e.target as HTMLInputElement).value)}/>
);
yield <br/>;
if (this.statusString()) {
yield <p>{this.statusString()}</p>;
} else if (!this.reserveCreationInfo()) {
yield <p>Checking URL, please wait ...</p>;
}
if (this.reserveCreationInfo()) {
if (this.detailCollapsed()) {
yield (
<button className="linky"
onClick={() => this.detailCollapsed(false)}>
show more details
</button>
);
} else {
yield (
<button className="linky"
onClick={() => this.detailCollapsed(true)}>
hide details
</button>
);
yield (
<div>
{renderReserveCreationDetails(this.reserveCreationInfo()!)}
</div>
);
}
}
} }
return "";
return Array.from(f.call(this));
} }
} }