Browse Source

exchange rate / currency improvements:

- support for multiple currency exchange rates
- support for inline display of exchange currencies
- a little frontend cleanup
fix-133-memory-crash
Dan Janosik 6 years ago
parent
commit
e9e8b57d3f
  1. 6
      app.js
  2. 52
      app/coins/btc.js
  3. 27
      app/coins/ltc.js
  4. 178
      app/utils.js
  5. 11
      views/includes/value-display.pug
  6. 4
      views/index.pug
  7. 20
      views/mempool-summary.pug

6
app.js

@ -338,12 +338,12 @@ app.runOnStartup = function() {
}); });
} }
if (global.exchangeRate == null) { if (global.exchangeRates == null) {
utils.refreshExchangeRate(); utils.refreshExchangeRates();
} }
// refresh exchange rate periodically // refresh exchange rate periodically
setInterval(utils.refreshExchangeRate, 1800000); setInterval(utils.refreshExchangeRates, 1800000);
utils.logMemoryUsage(); utils.logMemoryUsage();
setInterval(utils.logMemoryUsage, 5000); setInterval(utils.logMemoryUsage, 5000);

52
app/coins/btc.js

@ -1,8 +1,9 @@
var Decimal = require("decimal.js"); var Decimal = require("decimal.js");
Decimal8 = Decimal.clone({ precision:8, rounding:8 }); Decimal8 = Decimal.clone({ precision:8, rounding:8 });
var btcCurrencyUnits = [ var currencyUnits = [
{ {
type:"native",
name:"BTC", name:"BTC",
multiplier:1, multiplier:1,
default:true, default:true,
@ -10,23 +11,42 @@ var btcCurrencyUnits = [
decimalPlaces:8 decimalPlaces:8
}, },
{ {
type:"native",
name:"mBTC", name:"mBTC",
multiplier:1000, multiplier:1000,
values:["mbtc"], values:["mbtc"],
decimalPlaces:5 decimalPlaces:5
}, },
{ {
type:"native",
name:"bits", name:"bits",
multiplier:1000000, multiplier:1000000,
values:["bits"], values:["bits"],
decimalPlaces:2 decimalPlaces:2
}, },
{ {
type:"native",
name:"sat", name:"sat",
multiplier:100000000, multiplier:100000000,
values:["sat", "satoshi"], values:["sat", "satoshi"],
decimalPlaces:0 decimalPlaces:0
} },
{
type:"exchanged",
name:"USD",
multiplier:"usd",
values:["usd"],
decimalPlaces:2,
symbol:"$"
},
{
type:"exchanged",
name:"EUR",
multiplier:"eur",
values:["eur"],
decimalPlaces:2,
symbol:"€"
},
]; ];
module.exports = { module.exports = {
@ -43,9 +63,10 @@ module.exports = {
"https://raw.githubusercontent.com/btccom/Blockchain-Known-Pools/master/pools.json" "https://raw.githubusercontent.com/btccom/Blockchain-Known-Pools/master/pools.json"
], ],
maxBlockWeight: 4000000, maxBlockWeight: 4000000,
currencyUnits:btcCurrencyUnits, currencyUnits:currencyUnits,
currencyUnitsByName:{"BTC":btcCurrencyUnits[0], "mBTC":btcCurrencyUnits[1], "bits":btcCurrencyUnits[2], "sat":btcCurrencyUnits[3]}, currencyUnitsByName:{"BTC":currencyUnits[0], "mBTC":currencyUnits[1], "bits":currencyUnits[2], "sat":currencyUnits[3]},
baseCurrencyUnit:btcCurrencyUnits[3], baseCurrencyUnit:currencyUnits[3],
defaultCurrencyUnit:currencyUnits[0],
feeSatoshiPerByteBucketMaxima: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 50, 75, 100, 150], feeSatoshiPerByteBucketMaxima: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 50, 75, 100, 150],
genesisBlockHash: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", genesisBlockHash: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
genesisCoinbaseTransactionId: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", genesisCoinbaseTransactionId: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b",
@ -189,14 +210,25 @@ module.exports = {
} }
], ],
exchangeRateData:{ exchangeRateData:{
jsonUrl:"https://api.coinmarketcap.com/v1/ticker/Bitcoin/", jsonUrl:"https://api.coindesk.com/v1/bpi/currentprice.json",
exchangedCurrencyName:"usd",
responseBodySelectorFunction:function(responseBody) { responseBodySelectorFunction:function(responseBody) {
if (responseBody[0] && responseBody[0].price_usd) { //console.log("Exchange Rate Response: " + JSON.stringify(responseBody));
return responseBody[0].price_usd;
var exchangedCurrencies = ["USD", "GBP", "EUR"];
if (responseBody.bpi) {
var exchangeRates = {};
for (var i = 0; i < exchangedCurrencies.length; i++) {
if (responseBody.bpi[exchangedCurrencies[i]]) {
exchangeRates[exchangedCurrencies[i].toLowerCase()] = responseBody.bpi[exchangedCurrencies[i]].rate_float;
}
}
return exchangeRates;
} }
return -1; return null;
} }
}, },
blockRewardFunction:function(blockHeight) { blockRewardFunction:function(blockHeight) {

27
app/coins/ltc.js

@ -1,8 +1,9 @@
var Decimal = require("decimal.js"); var Decimal = require("decimal.js");
Decimal8 = Decimal.clone({ precision:8, rounding:8 }); Decimal8 = Decimal.clone({ precision:8, rounding:8 });
var ltcCurrencyUnits = [ var currencyUnits = [
{ {
type:"native",
name:"LTC", name:"LTC",
multiplier:1, multiplier:1,
default:true, default:true,
@ -10,23 +11,34 @@ var ltcCurrencyUnits = [
decimalPlaces:8 decimalPlaces:8
}, },
{ {
type:"native",
name:"lite", name:"lite",
multiplier:1000, multiplier:1000,
values:["lite"], values:["lite"],
decimalPlaces:5 decimalPlaces:5
}, },
{ {
type:"native",
name:"photon", name:"photon",
multiplier:1000000, multiplier:1000000,
values:["photon"], values:["photon"],
decimalPlaces:2 decimalPlaces:2
}, },
{ {
type:"native",
name:"litoshi", name:"litoshi",
multiplier:100000000, multiplier:100000000,
values:["litoshi", "lit"], values:["litoshi", "lit"],
decimalPlaces:0 decimalPlaces:0
} },
{
type:"exchanged",
name:"USD",
multiplier:"usd",
values:["usd"],
decimalPlaces:2,
symbol:"$"
},
]; ];
module.exports = { module.exports = {
@ -41,9 +53,10 @@ module.exports = {
"https://raw.githubusercontent.com/hashstream/pools/master/pools.json", "https://raw.githubusercontent.com/hashstream/pools/master/pools.json",
], ],
maxBlockWeight: 4000000, maxBlockWeight: 4000000,
currencyUnits:ltcCurrencyUnits, currencyUnits:currencyUnits,
currencyUnitsByName:{"LTC":ltcCurrencyUnits[0], "lite":ltcCurrencyUnits[1], "photon":ltcCurrencyUnits[2], "litoshi":ltcCurrencyUnits[3]}, currencyUnitsByName:{"LTC":currencyUnits[0], "lite":currencyUnits[1], "photon":currencyUnits[2], "litoshi":currencyUnits[3]},
baseCurrencyUnit:ltcCurrencyUnits[3], baseCurrencyUnit:currencyUnits[3],
defaultCurrencyUnit:currencyUnits[0],
feeSatoshiPerByteBucketMaxima: [5, 10, 25, 50, 100, 150, 200, 250], feeSatoshiPerByteBucketMaxima: [5, 10, 25, 50, 100, 150, 200, 250],
genesisBlockHash: "12a765e31ffd4059bada1e25190f6e98c99d9714d334efa41a195a7e7e04bfe2", genesisBlockHash: "12a765e31ffd4059bada1e25190f6e98c99d9714d334efa41a195a7e7e04bfe2",
genesisCoinbaseTransactionId: "97ddfbbae6be97fd6cdf3e7ca13232a3afff2353e29badfab7f73011edd4ced9", genesisCoinbaseTransactionId: "97ddfbbae6be97fd6cdf3e7ca13232a3afff2353e29badfab7f73011edd4ced9",
@ -119,10 +132,10 @@ module.exports = {
exchangedCurrencyName:"usd", exchangedCurrencyName:"usd",
responseBodySelectorFunction:function(responseBody) { responseBodySelectorFunction:function(responseBody) {
if (responseBody[0] && responseBody[0].price_usd) { if (responseBody[0] && responseBody[0].price_usd) {
return responseBody[0].price_usd; return {"usd":responseBody[0].price_usd};
} }
return -1; return null;
} }
}, },
blockRewardFunction:function(blockHeight) { blockRewardFunction:function(blockHeight) {

178
app/utils.js

@ -1,5 +1,6 @@
var Decimal = require("decimal.js"); var Decimal = require("decimal.js");
var request = require("request"); var request = require("request");
var qrcode = require("qrcode");
var config = require("./config.js"); var config = require("./config.js");
var coins = require("./coins.js"); var coins = require("./coins.js");
@ -83,23 +84,8 @@ function getRandomString(length, chars) {
var formatCurrencyCache = {}; var formatCurrencyCache = {};
function formatCurrencyAmountWithForcedDecimalPlaces(amount, formatType, forcedDecimalPlaces) { function getCurrencyFormatInfo(formatType) {
if (formatCurrencyCache[formatType]) { if (formatCurrencyCache[formatType] == null) {
var dec = new Decimal(amount);
dec = dec.times(formatCurrencyCache[formatType].multiplier);
var decimalPlaces = formatCurrencyCache[formatType].decimalPlaces;
if (decimalPlaces == 0 && dec < 1) {
decimalPlaces = 5;
}
if (forcedDecimalPlaces >= 0) {
decimalPlaces = forcedDecimalPlaces;
}
return addThousandsSeparators(dec.toDecimalPlaces(decimalPlaces)) + " " + formatCurrencyCache[formatType].name;
}
for (var x = 0; x < coins[config.coin].currencyUnits.length; x++) { for (var x = 0; x < coins[config.coin].currencyUnits.length; x++) {
var currencyUnit = coins[config.coin].currencyUnits[x]; var currencyUnit = coins[config.coin].currencyUnits[x];
@ -108,11 +94,24 @@ function formatCurrencyAmountWithForcedDecimalPlaces(amount, formatType, forcedD
if (currencyUnitValue == formatType) { if (currencyUnitValue == formatType) {
formatCurrencyCache[formatType] = currencyUnit; formatCurrencyCache[formatType] = currencyUnit;
}
}
}
}
if (formatCurrencyCache[formatType] != null) {
return formatCurrencyCache[formatType];
}
return null;
}
function formatCurrencyAmountWithForcedDecimalPlaces(amount, formatType, forcedDecimalPlaces) {
var formatInfo = getCurrencyFormatInfo(formatType);
if (formatInfo != null) {
var dec = new Decimal(amount); var dec = new Decimal(amount);
dec = dec.times(currencyUnit.multiplier);
var decimalPlaces = currencyUnit.decimalPlaces; var decimalPlaces = formatInfo.decimalPlaces;
if (decimalPlaces == 0 && dec < 1) { if (decimalPlaces == 0 && dec < 1) {
decimalPlaces = 5; decimalPlaces = 5;
} }
@ -121,7 +120,16 @@ function formatCurrencyAmountWithForcedDecimalPlaces(amount, formatType, forcedD
decimalPlaces = forcedDecimalPlaces; decimalPlaces = forcedDecimalPlaces;
} }
return addThousandsSeparators(dec.toDecimalPlaces(decimalPlaces)) + " " + currencyUnit.name; if (formatInfo.type == "native") {
dec = dec.times(formatInfo.multiplier);
return addThousandsSeparators(dec.toDecimalPlaces(decimalPlaces)) + " " + formatInfo.name;
} else if (formatInfo.type == "exchanged") {
if (global.exchangeRates != null && global.exchangeRates[formatInfo.multiplier] != null) {
dec = dec.times(global.exchangeRates[formatInfo.multiplier]);
return formatInfo.symbol + addThousandsSeparators(dec.toDecimalPlaces(decimalPlaces));
} }
} }
} }
@ -134,7 +142,7 @@ function formatCurrencyAmount(amount, formatType) {
} }
function formatCurrencyAmountInSmallestUnits(amount, forcedDecimalPlaces) { function formatCurrencyAmountInSmallestUnits(amount, forcedDecimalPlaces) {
return formatCurrencyAmountWithForcedDecimalPlaces(amount, coins[config.coin].currencyUnits[coins[config.coin].currencyUnits.length - 1].name, forcedDecimalPlaces); return formatCurrencyAmountWithForcedDecimalPlaces(amount, coins[config.coin].baseCurrencyUnit.name, forcedDecimalPlaces);
} }
// ref: https://stackoverflow.com/a/2901298/673828 // ref: https://stackoverflow.com/a/2901298/673828
@ -145,12 +153,12 @@ function addThousandsSeparators(x) {
return parts.join("."); return parts.join(".");
} }
function formatExchangedCurrency(amount) { function formatExchangedCurrency(amount, exchangeType) {
if (global.exchangeRate != null) { if (global.exchangeRates != null && global.exchangeRates[exchangeType.toLowerCase()] != null) {
var dec = new Decimal(amount); var dec = new Decimal(amount);
dec = dec.times(global.exchangeRate); dec = dec.times(global.exchangeRates[exchangeType.toLowerCase()]);
return addThousandsSeparators(dec.toDecimalPlaces(2)) + " " + coins[config.coin].exchangeRateData.exchangedCurrencyName; return "$" + addThousandsSeparators(dec.toDecimalPlaces(2));
} }
return ""; return "";
@ -267,28 +275,18 @@ function getBlockTotalFeesFromCoinbaseTxAndBlockHeight(coinbaseTx, blockHeight)
return totalOutput.minus(new Decimal(blockReward)); return totalOutput.minus(new Decimal(blockReward));
} }
function refreshExchangeRate() { function refreshExchangeRates() {
if (coins[config.coin].exchangeRateData) { if (coins[config.coin].exchangeRateData) {
request(coins[config.coin].exchangeRateData.jsonUrl, function(error, response, body) { request(coins[config.coin].exchangeRateData.jsonUrl, function(error, response, body) {
if (!error && response && response.statusCode && response.statusCode == 200) { if (!error && response && response.statusCode && response.statusCode == 200) {
var responseBody = JSON.parse(body); var responseBody = JSON.parse(body);
var exchangeRate = coins[config.coin].exchangeRateData.responseBodySelectorFunction(responseBody); var exchangeRates = coins[config.coin].exchangeRateData.responseBodySelectorFunction(responseBody);
if (exchangeRate > 0) { if (exchangeRates != null) {
global.exchangeRate = exchangeRate; global.exchangeRates = exchangeRates;
global.exchangeRateUpdateTime = new Date(); global.exchangeRatesUpdateTime = new Date();
if (global.influxdb) { console.log("Using exchange rates: " + JSON.stringify(global.exchangeRates) + " starting at " + global.exchangeRatesUpdateTime);
global.influxdb.writePoints([{
measurement: `exchange_rates.${coins[config.coin].ticker.toLowerCase()}_usd`,
fields:{value:parseFloat(exchangeRate)}
}]).catch(err => {
console.error(`Error saving data to InfluxDB: ${err.stack}`)
});
}
console.log("Using exchange rate: " + global.exchangeRate + " USD/" + coins[config.coin].name + " starting at " + global.exchangeRateUpdateTime);
} else { } else {
console.log("Unable to get exchange rate data"); console.log("Unable to get exchange rate data");
@ -386,12 +384,101 @@ function formatLargeNumber(n, decimalPlaces) {
return [new Decimal(n).toDecimalPlaces(decimalPlaces), {}]; return [new Decimal(n).toDecimalPlaces(decimalPlaces), {}];
} }
function rgbToHsl(r, g, b) {
r /= 255, g /= 255, b /= 255;
var max = Math.max(r, g, b), min = Math.min(r, g, b);
var h, s, l = (max + min) / 2;
if(max == min){
h = s = 0; // achromatic
}else{
var d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch(max){
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return {h:h, s:s, l:l};
}
function colorHexToRgb(hex) {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, function(m, r, g, b) {
return r + r + g + g + b + b;
});
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
function colorHexToHsl(hex) {
var rgb = colorHexToRgb(hex);
return rgbToHsl(rgb.r, rgb.g, rgb.b);
}
function logError(errorId, err, optionalUserData = null) {
console.log("Error " + errorId + ": " + err + ", json: " + JSON.stringify(err) + (optionalUserData != null ? (", userData: " + optionalUserData) : ""));
}
function buildQrCodeUrls(strings) {
return new Promise(function(resolve, reject) {
var promises = [];
var qrcodeUrls = {};
for (var i = 0; i < strings.length; i++) {
promises.push(new Promise(function(resolve2, reject2) {
buildQrCodeUrl(strings[i], qrcodeUrls).then(function() {
resolve2();
}).catch(function(err) {
reject2(err);
});
}));
}
Promise.all(promises).then(function(results) {
resolve(qrcodeUrls);
}).catch(function(err) {
reject(err);
});
});
}
function buildQrCodeUrl(str, results) {
return new Promise(function(resolve, reject) {
qrcode.toDataURL(str, function(err, url) {
if (err) {
utils.logError("2q3ur8fhudshfs", err, str);
reject(err);
return;
}
results[str] = url;
resolve();
});
});
}
module.exports = { module.exports = {
redirectToConnectPageIfNeeded: redirectToConnectPageIfNeeded, redirectToConnectPageIfNeeded: redirectToConnectPageIfNeeded,
hex2ascii: hex2ascii, hex2ascii: hex2ascii,
splitArrayIntoChunks: splitArrayIntoChunks, splitArrayIntoChunks: splitArrayIntoChunks,
getRandomString: getRandomString, getRandomString: getRandomString,
getCurrencyFormatInfo: getCurrencyFormatInfo,
formatCurrencyAmount: formatCurrencyAmount, formatCurrencyAmount: formatCurrencyAmount,
formatCurrencyAmountWithForcedDecimalPlaces: formatCurrencyAmountWithForcedDecimalPlaces, formatCurrencyAmountWithForcedDecimalPlaces: formatCurrencyAmountWithForcedDecimalPlaces,
formatExchangedCurrency: formatExchangedCurrency, formatExchangedCurrency: formatExchangedCurrency,
@ -402,9 +489,14 @@ module.exports = {
logMemoryUsage: logMemoryUsage, logMemoryUsage: logMemoryUsage,
getMinerFromCoinbaseTx: getMinerFromCoinbaseTx, getMinerFromCoinbaseTx: getMinerFromCoinbaseTx,
getBlockTotalFeesFromCoinbaseTxAndBlockHeight: getBlockTotalFeesFromCoinbaseTxAndBlockHeight, getBlockTotalFeesFromCoinbaseTxAndBlockHeight: getBlockTotalFeesFromCoinbaseTxAndBlockHeight,
refreshExchangeRate: refreshExchangeRate, refreshExchangeRates: refreshExchangeRates,
parseExponentStringDouble: parseExponentStringDouble, parseExponentStringDouble: parseExponentStringDouble,
formatLargeNumber: formatLargeNumber, formatLargeNumber: formatLargeNumber,
geoLocateIpAddresses: geoLocateIpAddresses, geoLocateIpAddresses: geoLocateIpAddresses,
getTxTotalInputOutputValues: getTxTotalInputOutputValues getTxTotalInputOutputValues: getTxTotalInputOutputValues,
rgbToHsl: rgbToHsl,
colorHexToRgb: colorHexToRgb,
colorHexToHsl: colorHexToHsl,
logError: logError,
buildQrCodeUrls: buildQrCodeUrls
}; };

11
views/includes/value-display.pug

@ -1,8 +1,15 @@
- var currencyFormatInfo = utils.getCurrencyFormatInfo(currencyFormatType);
if (currencyValue > 0) if (currencyValue > 0)
span(class="monospace") #{utils.formatCurrencyAmount(currencyValue, currencyFormatType)} span(class="monospace") #{utils.formatCurrencyAmount(currencyValue, currencyFormatType)}
if (global.exchangeRate) if (currencyFormatInfo.type == "native")
if (global.exchangeRates)
span span
span(data-toggle="tooltip", title=utils.formatExchangedCurrency(currencyValue)) span(data-toggle="tooltip", title=utils.formatExchangedCurrency(currencyValue, "usd"))
i(class="fas fa-exchange-alt")
else if (currencyFormatInfo.type == "exchanged")
span
span(data-toggle="tooltip", title=utils.formatCurrencyAmount(currencyValue, coinConfig.defaultCurrencyUnit.name))
i(class="fas fa-exchange-alt") i(class="fas fa-exchange-alt")
else else
span(class="monospace") 0 span(class="monospace") 0

4
views/index.pug

@ -96,8 +96,8 @@ block content
span(data-toggle="tooltip", title=("Exchange-rate data from: " + coinConfig.exchangeRateData.jsonUrl)) span(data-toggle="tooltip", title=("Exchange-rate data from: " + coinConfig.exchangeRateData.jsonUrl))
i(class="fas fa-info-circle") i(class="fas fa-info-circle")
if (global.exchangeRate) if (global.exchangeRates)
p(class="lead") #{utils.formatExchangedCurrency(1.0)} p(class="lead") #{utils.formatExchangedCurrency(1.0, "usd")}
else else
p(class="lead") - p(class="lead") -

20
views/mempool-summary.pug

@ -39,11 +39,9 @@ block content
if (getmempoolinfo.size > 0) if (getmempoolinfo.size > 0)
tr tr
td(class="properties-header") Average Fee td(class="properties-header") Average Fee
td(class="monospace") #{utils.formatCurrencyAmount(mempoolstats["averageFee"], currencyFormatType)} td(class="monospace")
if (global.exchangeRate) - var currencyValue = mempoolstats["averageFee"];
span include ./includes/value-display.pug
span(data-toggle="tooltip", title=utils.formatExchangedCurrency(mempoolstats["averageFee"]))
i(class="fas fa-exchange-alt")
tr tr
td(class="properties-header") Average Fee per Byte td(class="properties-header") Average Fee per Byte
@ -119,17 +117,17 @@ block content
tr tr
td #{mempoolstats["satoshiPerByteBucketLabels"][index]} td #{mempoolstats["satoshiPerByteBucketLabels"][index]}
td(class="text-right monospace") #{item["count"].toLocaleString()} td(class="text-right monospace") #{item["count"].toLocaleString()}
td(class="text-right monospace") #{utils.formatCurrencyAmount(item["totalFees"], currencyFormatType)} td(class="text-right monospace")
- var currencyValue = item["totalFees"];
include ./includes/value-display.pug
if (item["totalBytes"] > 0) if (item["totalBytes"] > 0)
- var avgFee = item["totalFees"] / item["count"]; - var avgFee = item["totalFees"] / item["count"];
- var avgFeeRate = item["totalFees"] / item["totalBytes"]; - var avgFeeRate = item["totalFees"] / item["totalBytes"];
td(class="text-right monospace") #{utils.formatCurrencyAmount(avgFee, currencyFormatType)} td(class="text-right monospace")
if (global.exchangeRate) - var currencyValue = avgFee;
span include ./includes/value-display.pug
span(data-toggle="tooltip", title=utils.formatExchangedCurrency(avgFee))
i(class="fas fa-exchange-alt")
td(class="text-right monospace") #{utils.formatCurrencyAmountInSmallestUnits(avgFeeRate, 2)}/B td(class="text-right monospace") #{utils.formatCurrencyAmountInSmallestUnits(avgFeeRate, 2)}/B
else else

Loading…
Cancel
Save