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

View File

@ -18,6 +18,7 @@
<script src="../lib/vendor/system-csp-production.src.js"></script>
<script src="../lib/module-trampoline.js"></script>
<style>
#main {
border: solid 1px black;
@ -47,6 +48,17 @@
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 {
width: 25em;
}

View File

@ -32,35 +32,37 @@ import {getReserveCreationInfo} from "../lib/wallet/wxApi";
let h = preact.h;
/**
* Execute something after a delay, with the possibility
* to reset the delay.
*/
class DelayTimer {
ms: number;
f: () => void;
timerId: number|undefined = undefined;
function delay<T>(delayMs: number, value: T): Promise<T> {
return new Promise<T>((resolve, reject) => {
setTimeout(() => resolve(value), delayMs);
});
}
constructor(ms: number, f: () => void) {
this.f = f;
this.ms = ms;
class EventTrigger {
triggerResolve: any;
triggerPromise: Promise<boolean>;
constructor() {
this.reset();
}
bump() {
this.stop();
const handler = () => {
this.f();
};
this.timerId = window.setTimeout(handler, this.ms);
private reset() {
this.triggerPromise = new Promise<boolean>((resolve, reject) => {
this.triggerResolve = resolve;
});
}
stop() {
if (this.timerId != undefined) {
window.clearTimeout(this.timerId);
}
trigger() {
this.triggerResolve(false);
this.reset();
}
async wait(delayMs: number): Promise<boolean> {
return await Promise.race([this.triggerPromise, delay(delayMs, true)]);
}
}
interface StateHolder<T> {
(): T;
(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 countByPub: {[s: string]: number} = {};
@ -153,6 +159,17 @@ function getSuggestedExchange(currency: string): Promise<string> {
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 {
suggestedExchangeUrl: string;
amount: AmountJson;
@ -162,84 +179,77 @@ interface ExchangeSelectionProps {
class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> {
complexViewRequested: StateHolder<boolean> = this.makeState(false);
statusString: StateHolder<string|null> = this.makeState(null);
reserveCreationInfo: StateHolder<ReserveCreationInfo|null> = this.makeState(
null);
url: StateHolder<string|null> = this.makeState(null);
detailCollapsed: StateHolder<boolean> = this.makeState(true);
private timer: DelayTimer;
isValidExchange: boolean;
updateEvent = new EventTrigger();
constructor(props: ExchangeSelectionProps) {
super(props);
this.timer = new DelayTimer(800, () => this.update());
this.url(props.suggestedExchangeUrl || null);
this.update();
this.onUrlChanged(props.suggestedExchangeUrl || null);
}
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 {
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 (
<div>
{header}
{this.viewSimple()}
</div>);
}
viewSimple() {
let advancedButton = (
<button className="linky"
onClick={() => this.complexViewRequested(true)}>
advanced options
</button>
<p>
{"You are about to withdraw "}
<strong>{amountToPretty(props.amount)}</strong>
{" from your bank account into your wallet."}
</p>
<p>
The exchange provider will charge
{" "}
{this.renderFee()}
{" "}
in fees.
</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);
}
/**
* 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.timer.stop();
const doUpdate = () => {
this.reserveCreationInfo(null);
if (!this.url()) {
this.statusString = i18n`Error: URL is empty`;
m.redraw(true);
return;
this.statusString(null);
let parsedUrl = URI(this.url()!);
if (parsedUrl.is("relative")) {
this.statusString(i18n`Error: URL may not be relative`);
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() {
this.isValidExchange = false;
this.statusString(null);
this.reserveCreationInfo(null);
}
@ -338,75 +336,28 @@ class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> {
chrome.runtime.sendMessage({type: 'create-reserve', detail: d}, cb);
}
onUrlChanged(url: string) {
async onUrlChanged(url: string|null) {
this.reset();
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() {
function *f(): IterableIterator<any> {
if (this.reserveCreationInfo()) {
let {overhead, withdrawFee} = this.reserveCreationInfo()!;
let totalCost = Amounts.add(overhead, withdrawFee).amount;
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>
);
}
}
renderStatus(): any {
if (this.statusString()) {
return <p><strong style="color: red;">{this.statusString()}</strong></p>;
} else if (!this.reserveCreationInfo()) {
return <p>Checking URL, please wait ...</p>;
}
return Array.from(f.call(this));
return "";
}
}