From a118db43c43bf94e05ecaef3d04f6d26b9465fcb Mon Sep 17 00:00:00 2001 From: BreadJS <83626012+BreadJS@users.noreply.github.com> Date: Sun, 9 Jul 2023 21:19:55 +0200 Subject: [PATCH 1/3] New 'Governance' page (#145) * [HTML+JS+CSS] New 'Governance' page * [HTML+CSS] Add back 'Create Proposal' button * Dynamically set Max Budget + Superblock Countdown * Implement basic Governance currency displays * Add status, net votes and refined currency display * Add live funding status * Prettier * Add mobile display currency * Just a clicky pointer * Fix Governance clock for non-wallet users * Update scripts/global.js Co-authored-by: Duddino * [JS] Add finalise button * [JS] Add JSDoc Typing to flipdown * [JS] Update flipdown when switch networks * Gracefully cancel proposal creation + Prettier * Prettier * [JS] Finalization under status with btn * [HTML] Remove old create proposal btn * [JS] updateGovernanceTab when switch networks * [JS] Remove `MPW.` and linting * [JS] Fix linting issues * Remove unnecessary database instances * Add real-time Finalisation Status + Prettier --------- Co-authored-by: JSKitty Co-authored-by: Duddino --- assets/style/style.css | 417 +++++++++++++++++++++++++++++ index.template.html | 63 ++++- scripts/database.js | 2 +- scripts/flipdown.js | 415 ++++++++++++++++++++++++++++ scripts/global.js | 595 ++++++++++++++++++++++++++++++++++------- scripts/index.js | 3 + scripts/masternode.js | 13 +- scripts/settings.js | 2 + 8 files changed, 1404 insertions(+), 106 deletions(-) create mode 100644 scripts/flipdown.js diff --git a/assets/style/style.css b/assets/style/style.css index 3bb323cfc..8bd880287 100644 --- a/assets/style/style.css +++ b/assets/style/style.css @@ -2887,6 +2887,423 @@ select.form-control option { z-index: 200; } + +/* Flipdown styling (flipdown.js library) */ +.flipdown.flipdown__theme-dark { + font-family: sans-serif; + font-weight: bold; +} + +.flipdown.flipdown__theme-dark .rotor-group-heading:before { + font-size:13px; + color:#cac9d2; +} + +.flipdown.flipdown__theme-dark .rotor-group:nth-child(n+2):nth-child(-n+3):before, +.flipdown.flipdown__theme-dark .rotor-group:nth-child(n+2):nth-child(-n+3):after { + background-color: #31125f; +} + +.flipdown.flipdown__theme-dark .rotor, +.flipdown.flipdown__theme-dark .rotor-top, +.flipdown.flipdown__theme-dark .rotor-leaf-front { + color: #FFFFFF; + background-color: #31125f; +} + +.flipdown.flipdown__theme-dark .rotor-bottom, +.flipdown.flipdown__theme-dark .rotor-leaf-rear { + color: #EFEFEF; + background-color: #461989; +} + +.flipdown.flipdown__theme-dark .rotor:after { + border-top: solid 1px #31125f; +} + +.flipdown { + overflow: visible; + margin-top:7px; +} + +.flipdown .rotor-group { + position: relative; + float: left; + padding-right: 30px; +} + +.flipdown .rotor-group:last-child { + padding-right: 0; +} + +.flipdown .rotor-group-heading:before { + display: block; + height: 30px; + line-height: 30px; + text-align: center; +} + +.flipdown .rotor-group:nth-child(1) .rotor-group-heading:before { + content: attr(data-before); +} + +.flipdown .rotor-group:nth-child(2) .rotor-group-heading:before { + content: attr(data-before); +} + +.flipdown .rotor-group:nth-child(3) .rotor-group-heading:before { + content: attr(data-before); +} + +.flipdown .rotor-group:nth-child(4) .rotor-group-heading:before { + content: attr(data-before); +} + +.flipdown .rotor-group:nth-child(n+2):nth-child(-n+3):before { + content: ''; + position: absolute; + bottom: 20px; + left: 85px; + width: 10px; + height: 10px; + border-radius: 50%; +} + +.flipdown .rotor-group:nth-child(n+2):nth-child(-n+3):after { + content: ''; + position: absolute; + bottom: 35px; + left: 85px; + width: 10px; + height: 10px; + border-radius: 50%; +} + +.flipdown .rotor { + position: relative; + float: left; + width: 35px; + height: 60px; + margin: 0px 5px 0px 0px; + border-radius: 4px; + font-size: 48px; + text-align: center; + perspective: 200px; +} + +.flipdown .rotor:last-child { + margin-right: 0; +} + +.flipdown .rotor-top, +.flipdown .rotor-bottom { + overflow: hidden; + position: absolute; + width: 35px; + height: 30px; +} + +.flipdown .rotor-leaf { + z-index: 1; + position: absolute; + width: 35px; + height: 60px; + transform-style: preserve-3d; + transition: transform 0s; +} + +.flipdown .rotor-leaf.flipped { + transform: rotateX(-180deg); + transition: all 0.5s ease-in-out; +} + +.flipdown .rotor-leaf-front, +.flipdown .rotor-leaf-rear { + overflow: hidden; + position: absolute; + width: 35px; + height: 30px; + margin: 0; + transform: rotateX(0deg); + backface-visibility: hidden; + -webkit-backface-visibility: hidden; +} + +.flipdown .rotor-leaf-front { + line-height: 60px; + border-radius: 4px 4px 0px 0px; +} + +.flipdown .rotor-leaf-rear { + line-height: 0px; + border-radius: 0px 0px 4px 4px; + transform: rotateX(-180deg); +} + +.flipdown .rotor-top { + line-height: 60px; + border-radius: 4px 4px 0px 0px; +} + +.flipdown .rotor-bottom { + bottom: 0; + line-height: 0px; + border-radius: 0px 0px 4px 4px; +} + +.flipdown .rotor:after { + content: ''; + z-index: 2; + position: absolute; + bottom: 0px; + left: 0px; + width: 35px; + height: 30px; + border-radius: 0px 0px 4px 4px; +} + +@media (max-width: 550px) { + .flipdown { + width: 312px; + height: 70px; + } + + .flipdown .rotor { + font-size: 2.2rem; + margin-right: 3px; + } + + .flipdown .rotor, + .flipdown .rotor-leaf, + .flipdown .rotor-leaf-front, + .flipdown .rotor-leaf-rear, + .flipdown .rotor-top, + .flipdown .rotor-bottom, + .flipdown .rotor:after { + width: 30px; + } + + .flipdown .rotor-group { + padding-right: 20px; + } + + .flipdown .rotor-group:last-child { + padding-right: 0px; + } + + .flipdown .rotor-group-heading:before { + font-size: 0.8rem; + height: 20px; + line-height: 20px; + } + + .flipdown .rotor-group:nth-child(n+2):nth-child(-n+3):before, + .flipdown .rotor-group:nth-child(n+2):nth-child(-n+3):after { + left: 69px; + } + + .flipdown .rotor-group:nth-child(n+2):nth-child(-n+3):before { + bottom: 13px; + height: 8px; + width: 8px; + } + + .flipdown .rotor-group:nth-child(n+2):nth-child(-n+3):after { + bottom: 29px; + height: 8px; + width: 8px; + } + + .flipdown .rotor-leaf-front, + .flipdown .rotor-top { + line-height: 50px; + } + + .flipdown .rotor-leaf, + .flipdown .rotor { + height: 50px; + } + + .flipdown .rotor-leaf-front, + .flipdown .rotor-leaf-rear, + .flipdown .rotor-top, + .flipdown .rotor-bottom, + .flipdown .rotor:after { + height: 25px; + } +} + +.governLink:hover { + text-decoration: underline!important; +} + +.governInstallments { + color: #d1d1d1; + line-height: 17px; + display: block; + font-size: 13px; +} + +.governMarked { + color: hsl(265 100% 67% / 1); + font-weight: 700; +} + +.governValues { + line-height: 18px; + display: block; + margin-bottom: 8px; +} + +.governFiatSize { + font-size:13px; +} + +.governLinkIco { + font-size: 11px; + margin-left: 3px; + color: #bdbdbd; +} + +.governBudgetCard { + font-weight: 700; + font-size:15px; + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; +} + +.governPayoutTime { + font-weight: 700; + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; +} + +.governTable thead tr td { + font-size:13px; + border-top:0px; + background-color:#00000024; +} + +.governTable .btlr-7p { + border-top-left-radius: 7px; +} + +.governTable .btrr-7p { + border-top-right-radius: 7px; +} + +.governTable .bblr-7p { + border-bottom-left-radius: 7px; +} + +.governTable .bbrr-7p { + border-bottom-right-radius: 7px; +} + +.governTable tbody tr td { + border-top: 1px solid #a7a7a72b; +} + +.governTable tbody tr:nth-child(4n-1) { + background-color:#0000000f!important; +} + +.governTable tbody tr:nth-child(even) { + background-color:#0000000f!important; +} + +.governTable tbody tr { + transition: all 0.125s ease-in-out; +} + +.governTable tbody tr:hover { + background-color:#ffffff0a!important; +} + +.governTable .votesYes { + color:#5bd376; +} + +.governTable .votesNo { + color:#df4b6c; +} + +.governTable .votesBg { + background-color: #00000021; + padding: 3px 6px; + border-radius: 5px; +} + +.governTable .governStatusCol { + background-color: #00000017; +} + +.governTable .governStatusCol .governArrow { + position: absolute; + left: -11px; + margin-top: -49px; + background-color: #00000045; + height: 25px; + width: 25px; + border-radius: 50%; +} + +.governTable .governStatusCol .governArrow i { + position:relative; + top:1px; +} + +.governMobDot { + display: inline-block; + border-radius:50%; + height: 11px; + width: 11px; + margin-right: 6px; + margin-left: 17px; + background-color:#8d60e6; +} + +.governHr { + border-top: 1px solid #a7a7a72b; +} + +.governAdd { + position: absolute; + right: 0px; + height: 31px; + width: 31px; + padding: 7px 10px; + margin-top: 6px; +} + +.fs-13 { + font-size:13px; +} + +.fw-600 { + font-weight: 600; +} + +@media (min-width: 992px) { + .for-mobile { + display:none!important; + } +} + +@media (max-width: 992px) { + .for-desktop { + display:none!important; + } + + .governPayoutTime { + padding-top: 26px; + } +} + .notifyWrapper { opacity: 1; z-index: 999999; diff --git a/index.template.html b/index.template.html index c93047b1f..b56a28ab4 100644 --- a/index.template.html +++ b/index.template.html @@ -939,15 +939,51 @@

Governance

From this tab you can check the proposals and, if you have a masternode, be a part of the DAO and vote!

- + + +
+
+ Monthly Budget + +
+ - PIV +
+ - +
+
+
+ Budget Allocated + +
+ - PIV +
+ - +
+
+
+ Next Treasury Payout +
+
+
+ Budget Allocated + +
+ - PIV +
+ - +
+
+
+ + +
+
- - - - - + + + + +
Name Payment Votes Vote -
-
STATUS NAME PAYMENT VOTES VOTE
@@ -956,12 +992,13 @@

Governance

Contested Proposals

These are proposals that received an overwhelming amount of downvotes, making it likely spam or a highly contestable proposal.


- +
- - - - + + + + +
Name Payment Votes Vote STATUS NAME PAYMENT VOTES VOTE
diff --git a/scripts/database.js b/scripts/database.js index 19ef144a0..690d90300 100644 --- a/scripts/database.js +++ b/scripts/database.js @@ -91,7 +91,7 @@ export class Database { const store = this.#db .transaction('accounts', 'readwrite') .objectStore('accounts'); - // When the account system is gonig to be added, the key is gonna be the publicKey + // When the account system is going to be added, the key is gonna be the publicKey await store.put({ ...oldAccount, ...newAccount }, 'account'); } diff --git a/scripts/flipdown.js b/scripts/flipdown.js new file mode 100644 index 000000000..aa3bf16af --- /dev/null +++ b/scripts/flipdown.js @@ -0,0 +1,415 @@ +/** + * @name FlipDown + * @description Flip styled countdown clock + * @author Peter Butcher (PButcher) + * @param {number} uts - Time to count down to as unix timestamp + * @param {string} el - DOM element to attach FlipDown to + * @param {object} opt - Optional configuration settings + **/ +export class FlipDown { + constructor(uts, el = 'flipdown', opt = {}) { + // If uts is not specified + if (typeof uts !== 'number') { + throw new Error( + `FlipDown: Constructor expected unix timestamp, got ${typeof uts} instead.` + ); + } + + // If opt is specified, but not el + if (typeof el === 'object') { + opt = el; + el = 'flipdown'; + } + + // FlipDown version + this.version = '0.3.2'; + + // Initialised? + this.initialised = false; + + // Time at instantiation in seconds + this.now = this._getTime(); + + // UTS to count down to + this.epoch = uts; + + // UTS passed to FlipDown is in the past + this.countdownEnded = false; + + // User defined callback for countdown end + this.hasEndedCallback = null; + + // FlipDown DOM element + this.element = document.getElementById(el); + + // Rotor DOM elements + this.rotors = []; + this.rotorLeafFront = []; + this.rotorLeafRear = []; + this.rotorTops = []; + this.rotorBottoms = []; + + // Interval + this.countdown = null; + + // Number of days remaining + this.daysRemaining = 0; + + // Clock values as numbers + this.clockValues = {}; + + // Clock values as strings + this.clockStrings = {}; + + // Clock values as array + this.clockValuesAsString = []; + this.prevClockValuesAsString = []; + + // Parse options + this.opts = this._parseOptions(opt); + + // Set options + this._setOptions(); + } + + /** + * @name start + * @description Start the countdown + * @author PButcher + **/ + start() { + // Initialise the clock + if (!this.initialised) this._init(); + + // Set up the countdown interval + this.countdown = setInterval(this._tick.bind(this), 1000); + + // Chainable + return this; + } + + /** + * @name ifEnded + * @description Call a function once the countdown ends + * @author PButcher + * @param {function} cb - Callback + **/ + ifEnded(cb) { + this.hasEndedCallback = function () { + cb(); + this.hasEndedCallback = null; + }; + + // Chainable + return this; + } + + /** + * @name _getTime + * @description Get the time in seconds (unix timestamp) + * @author PButcher + **/ + _getTime() { + return new Date().getTime() / 1000; + } + + /** + * @name _hasCountdownEnded + * @description Has the countdown ended? + * @author PButcher + **/ + _hasCountdownEnded() { + // Countdown has ended + if (this.epoch - this.now < 0) { + this.countdownEnded = true; + + // Fire the ifEnded callback once if it was set + if (this.hasEndedCallback != null) { + // Call ifEnded callback + this.hasEndedCallback(); + + // Remove the callback + this.hasEndedCallback = null; + } + + return true; + + // Countdown has not ended + } else { + this.countdownEnded = false; + return false; + } + } + + /** + * @name _parseOptions + * @description Parse any passed options + * @param {object} opt - Optional configuration settings + * @author PButcher + **/ + _parseOptions(opt) { + let headings = ['Days', 'Hours', 'Minutes', 'Seconds']; + if (opt.headings && opt.headings.length === 4) { + headings = opt.headings; + } + return { + // Theme + theme: Object.prototype.hasOwnProperty.call(opt, 'theme') + ? opt.theme + : 'dark', + headings, + }; + } + + /** + * @name _setOptions + * @description Set optional configuration settings + * @author PButcher + **/ + _setOptions() { + // Apply theme + this.element.classList.add(`flipdown__theme-${this.opts.theme}`); + } + + /** + * @name _init + * @description Initialise the countdown + * @author PButcher + **/ + _init() { + this.initialised = true; + + // Check whether countdown has ended and calculate how many digits the day counter needs + if (this._hasCountdownEnded()) { + this.daysremaining = 0; + } else { + this.daysremaining = Math.floor( + (this.epoch - this.now) / 86400 + ).toString().length; + } + var dayRotorCount = this.daysremaining <= 2 ? 2 : this.daysremaining; + + // Create and store rotors + for (var i = 0; i < dayRotorCount + 6; i++) { + this.rotors.push(this._createRotor(0)); + } + + // Create day rotor group + var dayRotors = []; + for (var j = 0; j < dayRotorCount; j++) { + dayRotors.push(this.rotors[j]); + } + this.element.appendChild(this._createRotorGroup(dayRotors, 0)); + + // Create other rotor groups + var count = dayRotorCount; + for (var k = 0; k < 3; k++) { + var otherRotors = []; + for (var l = 0; l < 2; l++) { + otherRotors.push(this.rotors[count]); + count++; + } + this.element.appendChild( + this._createRotorGroup(otherRotors, k + 1) + ); + } + + // Store and convert rotor nodelists to arrays + this.rotorLeafFront = Array.prototype.slice.call( + this.element.getElementsByClassName('rotor-leaf-front') + ); + this.rotorLeafRear = Array.prototype.slice.call( + this.element.getElementsByClassName('rotor-leaf-rear') + ); + this.rotorTop = Array.prototype.slice.call( + this.element.getElementsByClassName('rotor-top') + ); + this.rotorBottom = Array.prototype.slice.call( + this.element.getElementsByClassName('rotor-bottom') + ); + + // Set initial values; + this._tick(); + this._updateClockValues(true); + + return this; + } + + /** + * @name _createRotorGroup + * @description Add rotors to the DOM + * @author PButcher + * @param {array} rotors - A set of rotors + **/ + _createRotorGroup(rotors, rotorIndex) { + var rotorGroup = document.createElement('div'); + rotorGroup.className = 'rotor-group'; + var dayRotorGroupHeading = document.createElement('div'); + dayRotorGroupHeading.className = 'rotor-group-heading'; + dayRotorGroupHeading.setAttribute( + 'data-before', + this.opts.headings[rotorIndex] + ); + rotorGroup.appendChild(dayRotorGroupHeading); + appendChildren(rotorGroup, rotors); + return rotorGroup; + } + + /** + * @name _createRotor + * @description Create a rotor DOM element + * @author PButcher + * @param {number} v - Initial rotor value + **/ + _createRotor(v = 0) { + var rotor = document.createElement('div'); + var rotorLeaf = document.createElement('div'); + var rotorLeafRear = document.createElement('figure'); + var rotorLeafFront = document.createElement('figure'); + var rotorTop = document.createElement('div'); + var rotorBottom = document.createElement('div'); + rotor.className = 'rotor'; + rotorLeaf.className = 'rotor-leaf'; + rotorLeafRear.className = 'rotor-leaf-rear'; + rotorLeafFront.className = 'rotor-leaf-front'; + rotorTop.className = 'rotor-top'; + rotorBottom.className = 'rotor-bottom'; + rotorLeafRear.textContent = v; + rotorTop.textContent = v; + rotorBottom.textContent = v; + appendChildren(rotor, [rotorLeaf, rotorTop, rotorBottom]); + appendChildren(rotorLeaf, [rotorLeafRear, rotorLeafFront]); + return rotor; + } + + /** + * @name _tick + * @description Calculate current tick + * @author PButcher + **/ + _tick() { + // Get time now + this.now = this._getTime(); + + // Between now and epoch + var diff = this.epoch - this.now <= 0 ? 0 : this.epoch - this.now; + + // Days remaining + this.clockValues.d = Math.floor(diff / 86400); + diff -= this.clockValues.d * 86400; + + // Hours remaining + this.clockValues.h = Math.floor(diff / 3600); + diff -= this.clockValues.h * 3600; + + // Minutes remaining + this.clockValues.m = Math.floor(diff / 60); + diff -= this.clockValues.m * 60; + + // Seconds remaining + this.clockValues.s = Math.floor(diff); + + // Update clock values + this._updateClockValues(); + + // Has the countdown ended? + this._hasCountdownEnded(); + } + + /** + * @name _updateClockValues + * @description Update the clock face values + * @author PButcher + * @param {boolean} init - True if calling for initialisation + **/ + _updateClockValues(init = false) { + // Build clock value strings + this.clockStrings.d = pad(this.clockValues.d, 2); + this.clockStrings.h = pad(this.clockValues.h, 2); + this.clockStrings.m = pad(this.clockValues.m, 2); + this.clockStrings.s = pad(this.clockValues.s, 2); + + // Concat clock value strings + this.clockValuesAsString = ( + this.clockStrings.d + + this.clockStrings.h + + this.clockStrings.m + + this.clockStrings.s + ).split(''); + + // Update rotor values + // Note that the faces which are initially visible are: + // - rotorLeafFront (top half of current rotor) + // - rotorBottom (bottom half of current rotor) + // Note that the faces which are initially hidden are: + // - rotorTop (top half of next rotor) + // - rotorLeafRear (bottom half of next rotor) + this.rotorLeafFront.forEach((el, i) => { + el.textContent = this.prevClockValuesAsString[i]; + }); + + this.rotorBottom.forEach((el, i) => { + el.textContent = this.prevClockValuesAsString[i]; + }); + + function rotorTopFlip() { + this.rotorTop.forEach((el, i) => { + if (el.textContent != this.clockValuesAsString[i]) { + el.textContent = this.clockValuesAsString[i]; + } + }); + } + + function rotorLeafRearFlip() { + this.rotorLeafRear.forEach((el, i) => { + if (el.textContent != this.clockValuesAsString[i]) { + el.textContent = this.clockValuesAsString[i]; + el.parentElement.classList.add('flipped'); + var flip = setInterval( + function () { + el.parentElement.classList.remove('flipped'); + clearInterval(flip); + }.bind(this), + 500 + ); + } + }); + } + + // Init + if (!init) { + setTimeout(rotorTopFlip.bind(this), 500); + setTimeout(rotorLeafRearFlip.bind(this), 500); + } else { + rotorTopFlip.call(this); + rotorLeafRearFlip.call(this); + } + + // Save a copy of clock values for next tick + this.prevClockValuesAsString = this.clockValuesAsString; + } +} + +/** + * @name pad + * @description Prefix a number with zeroes + * @author PButcher + * @param {string} n - Number to pad + * @param {number} len - Desired length of number + **/ +function pad(n, len) { + n = n.toString(); + return n.length < len ? pad('0' + n, len) : n; +} + +/** + * @name appendChildren + * @description Add multiple children to an element + * @author PButcher + * @param {object} parent - Parent + **/ +function appendChildren(parent, children) { + children.forEach((el) => { + parent.appendChild(el); + }); +} diff --git a/scripts/global.js b/scripts/global.js index 578dcd108..5dc09a2ab 100644 --- a/scripts/global.js +++ b/scripts/global.js @@ -31,6 +31,7 @@ import { parseBIP21Request, isValidBech32, isBase64, + sleep, } from './misc.js'; import { cChainParams, COIN, MIN_PASS_LENGTH } from './chain_params.js'; import { decrypt } from './aes-gcm.js'; @@ -43,6 +44,7 @@ import { scanQRCode } from './scanner.js'; import { Database } from './database.js'; import bitjs from './bitTrx.js'; import { checkForUpgrades } from './changelog.js'; +import { FlipDown } from './flipdown.js'; /** A flag showing if base MPW is fully loaded or not */ export let fIsLoaded = false; @@ -132,8 +134,27 @@ export async function start() { domGenVanityWallet: document.getElementById('generateVanityWallet'), domGenHardwareWallet: document.getElementById('generateHardwareWallet'), //GOVERNANCE ELEMENTS + domGovTab: document.getElementById('governanceTab'), domGovProposalsTable: document.getElementById('proposalsTable'), domGovProposalsTableBody: document.getElementById('proposalsTableBody'), + domTotalGovernanceBudget: document.getElementById( + 'totalGovernanceBudget' + ), + domTotalGovernanceBudgetValue: document.getElementById( + 'totalGovernanceBudgetValue' + ), + domAllocatedGovernanceBudget: document.getElementById( + 'allocatedGovernanceBudget' + ), + domAllocatedGovernanceBudgetValue: document.getElementById( + 'allocatedGovernanceBudgetValue' + ), + domAllocatedGovernanceBudget2: document.getElementById( + 'allocatedGovernanceBudget2' + ), + domAllocatedGovernanceBudgetValue2: document.getElementById( + 'allocatedGovernanceBudgetValue2' + ), domGovProposalsContestedTable: document.getElementById( 'proposalsContestedTable' ), @@ -270,6 +291,7 @@ export async function start() { domWalletSettingsBtn: document.getElementById('settingsWalletBtn'), domDisplaySettingsBtn: document.getElementById('settingsDisplayBtn'), domVersion: document.getElementById('version'), + domFlipdown: document.getElementById('flipdown'), }; await i18nStart(); await loadImages(); @@ -369,6 +391,9 @@ export async function start() { // Display the password unlock upfront await accessOrImportWallet(); } + } else { + // Just load the block count, for use in non-wallet areas + getNetwork().getBlockCount(); } subscribeToNetworkEvents(); @@ -438,6 +463,12 @@ function subscribeToNetworkEvents() { // WALLET STATE DATA export const mempool = new Mempool(); let exportHidden = false; +let isTestnetLastState = cChainParams.current.isTestnet; + +/** + * @type {FlipDown | null} + */ +let governanceFlipdown = null; /** * Open a UI 'tab' menu, and close all other tabs, intended for frontend use @@ -502,6 +533,37 @@ export function updateTicker() { doms.domUnstakeAmountCoinsTicker.innerText = cChainParams.current.TICKER; } +/** + * Return locale settings best for displaying the user-selected currency + * @param {Number} nAmount - The amount in Currency + */ +export function optimiseCurrencyLocale(nAmount) { + // Allow manipulating the value, if necessary + let nValue = nAmount; + + // Find the best fitting native-locale + const cLocale = Intl.supportedValuesOf('currency').includes( + strCurrency.toUpperCase() + ) + ? { + style: 'currency', + currency: strCurrency, + currencyDisplay: 'narrowSymbol', + } + : { maximumFractionDigits: 8, minimumFractionDigits: 8 }; + + // Catch display edge-cases; like Satoshis having decimals. + switch (strCurrency) { + case 'sats': + nValue = Math.round(nValue); + cLocale.maximumFractionDigits = 0; + cLocale.minimumFractionDigits = 0; + } + + // Return display-optimised Value and Locale pair. + return { nValue, cLocale }; +} + /** * Update a 'price value' DOM display for the given balance type * @param {HTMLElement} domValue @@ -510,28 +572,11 @@ export function updateTicker() { export function updatePriceDisplay(domValue, fCold = false) { // Update currency values cMarket.getPrice(strCurrency).then((nPrice) => { - // Configure locale settings by detecting currency support - const cLocale = Intl.supportedValuesOf('currency').includes( - strCurrency.toUpperCase() - ) - ? { - style: 'currency', - currency: strCurrency, - currencyDisplay: 'narrowSymbol', - } - : { maximumFractionDigits: 8, minimumFractionDigits: 8 }; - // Calculate the value - let nValue = + const nCurrencyValue = ((fCold ? getStakingBalance() : getBalance()) / COIN) * nPrice; - // Handle certain edge-cases; like satoshis having decimals. - switch (strCurrency) { - case 'sats': - nValue = Math.round(nValue); - cLocale.maximumFractionDigits = 0; - cLocale.minimumFractionDigits = 0; - } + const { nValue, cLocale } = optimiseCurrencyLocale(nCurrencyValue); // Update the DOM domValue.innerText = nValue.toLocaleString('en-gb', cLocale); @@ -1113,7 +1158,7 @@ export function hideAllWalletOptions() { doms.domGenHardwareWallet.style.display = 'none'; } -async function govVote(hash, voteCode) { +export async function govVote(hash, voteCode) { if ( (await confirmPopup({ title: ALERTS.CONFIRM_POPUP_VOTE, @@ -1746,10 +1791,41 @@ export async function restoreWallet(strReason = '') { } } +/** A lock to prevent rendering the Governance Dashboard multiple times */ +let fRenderingGovernance = false; + /** * Fetch Governance data and re-render the Governance UI */ -async function updateGovernanceTab() { +export async function updateGovernanceTab() { + if (fRenderingGovernance) return; + fRenderingGovernance = true; + + // Setup the Superblock countdown (if not already done), no need to block thread with await, either. + let cNet = getNetwork(); + + // When switching to mainnet from testnet or vise versa, you ned to use an await on getBlockCount() or cNet.cachedBlockCount returns 0 + if (!isTestnetLastState == cChainParams.current.isTestnet) { + // Reset flipdown + governanceFlipdown = null; + doms.domFlipdown.innerHTML = ''; + + // Get new network blockcount + await getNetwork().getBlockCount(); + cNet = getNetwork(); + } + + // Update governance counter when testnet/mainnet has been switched + if (!governanceFlipdown && cNet.cachedBlockCount > 0) { + Masternode.getNextSuperblock().then((nSuperblock) => { + // The estimated time to the superblock (using the block target and remaining blocks) + const nTimestamp = + Date.now() / 1000 + (nSuperblock - cNet.cachedBlockCount) * 60; + governanceFlipdown = new FlipDown(nTimestamp).start(); + }); + isTestnetLastState = cChainParams.current.isTestnet; + } + // Fetch all proposals from the network const arrProposals = await Masternode.getProposals({ fAllowFinished: false, @@ -1771,6 +1847,104 @@ async function updateGovernanceTab() { renderProposals(arrStandard, false), renderProposals(arrContested, true), ]); + + // Remove lock + fRenderingGovernance = false; +} + +/** + * @typedef {Object} ProposalCache + * @property {number} nSubmissionHeight - The submission height of the proposal. + * @property {string} txid - The transaction ID of the proposal (string). + * @property {boolean} fFetching - Indicates whether the proposal is currently being fetched or not. + */ + +/** + * An array of Proposal Finalisation caches + * @type {Array} + */ +const arrProposalFinalisationCache = []; + +/** + * Asynchronously wait for a Proposal Tx to confirm, then cache the height. + * + * Do NOT await unless you want to lock the thread for a long time. + * @param {ProposalCache} cProposalCache - The proposal cache to wait for + * @returns {Promise} Returns `true` once the block height is cached + */ +async function waitForSubmissionBlockHeight(cProposalCache) { + let nHeight = null; + + // Wait in a permanent throttled loop until we successfully fetch the block + const cNet = getNetwork(); + while (true) { + // If a proposal is already fetching, then consequtive calls will be rejected + cProposalCache.fFetching = true; + + // Attempt to fetch the submission Tx (may not exist yet!) + let cTx = null; + try { + cTx = await cNet.getTxInfo(cProposalCache.txid); + } catch (_) {} + + if (!cTx || !cTx.blockHeight) { + // Didn't get the TX, throttle the thread by sleeping for a bit, then try again. + await sleep(30000); + } else { + nHeight = cTx.blockHeight; + break; + } + } + + // Update the proposal finalisation cache + cProposalCache.nSubmissionHeight = nHeight; + + return true; +} + +/** + * Create a Status String for a proposal's finalisation status + * @param {ProposalCache} cPropCache - The proposal cache to check + * @returns {string} The string status, for display purposes + */ +function getProposalFinalisationStatus(cPropCache) { + const cNet = getNetwork(); + const nConfsLeft = cPropCache.nSubmissionHeight + 6 - cNet.cachedBlockCount; + + if (cPropCache.nSubmissionHeight === 0 || cNet.cachedBlockCount === 0) { + return 'Confirming...'; + } else if (nConfsLeft > 0) { + return nConfsLeft + ' block' + (nConfsLeft === 1 ? '' : 's') + ' left'; + } else if (Math.abs(nConfsLeft) >= cChainParams.current.budgetCycleBlocks) { + return 'Proposal Expired'; + } else { + return 'Ready to submit'; + } +} + +/** + * + * @param {Object} cProposal - A local proposal to add to the cache tracker + * @returns {ProposalCache} - The finalisation cache object pointer of the local proposal + */ +function addProposalToFinalisationCache(cProposal) { + // If it exists, return the existing cache + /** @type ProposalCache */ + let cPropCache = arrProposalFinalisationCache.find( + (a) => a.txid === cProposal.mpw.txid + ); + if (cPropCache) return cPropCache; + + // Create a new cache + cPropCache = { + nSubmissionHeight: 0, + txid: cProposal.mpw.txid, + fFetching: false, + }; + arrProposalFinalisationCache.push(cPropCache); + + // Return the object 'pointer' in the array for further updating + return cPropCache; } /** @@ -1779,19 +1953,31 @@ async function updateGovernanceTab() { * @param {boolean} fContested - The proposal category */ async function renderProposals(arrProposals, fContested) { + // Set the total budget + doms.domTotalGovernanceBudget.innerText = ( + cChainParams.current.maxPayment / COIN + ).toLocaleString('en-gb'); + + // Update total budget in user's currency + const nPrice = await cMarket.getPrice(strCurrency); + const nCurrencyValue = (cChainParams.current.maxPayment / COIN) * nPrice; + const { nValue, cLocale } = optimiseCurrencyLocale(nCurrencyValue); + doms.domTotalGovernanceBudgetValue.innerHTML = + nValue.toLocaleString('en-gb', cLocale) + + ' ' + + strCurrency.toUpperCase() + + ''; + // Select the table based on the proposal category const domTable = fContested ? doms.domGovProposalsContestedTableBody : doms.domGovProposalsTableBody; // Render the proposals in the relevent table - domTable.innerHTML = ''; const database = await Database.getInstance(); const cMasternode = await database.getMasternode(); if (!fContested) { - const database = await Database.getInstance(); - const localProposals = (await database.getAccount())?.localProposals?.map((p) => { return { @@ -1820,91 +2006,207 @@ async function renderProposals(arrProposals, fContested) { }; }) ); + + // Fetch the Masternode count for proposal status calculations + const cMasternodes = await Masternode.getMasternodeCount(); + + let totalAllocatedAmount = 0; + + // Wipe the current table and start rendering proposals + let i = 0; + domTable.innerHTML = ''; for (const cProposal of arrProposals) { const domRow = domTable.insertRow(); + const domStatus = domRow.insertCell(); + domStatus.classList.add('governStatusCol'); + if (domTable.id == 'proposalsTableBody') { + domStatus.setAttribute( + 'onclick', + `if(document.getElementById('governMob${i}').classList.contains('d-none')) { document.getElementById('governMob${i}').classList.remove('d-none'); } else { document.getElementById('governMob${i}').classList.add('d-none'); }` + ); + } else if (domTable.id == 'proposalsContestedTableBody') { + domStatus.setAttribute( + 'onclick', + `if(document.getElementById('governMobCon${i}').classList.contains('d-none')) { document.getElementById('governMobCon${i}').classList.remove('d-none'); } else { document.getElementById('governMobCon${i}').classList.add('d-none'); }` + ); + } + + // Add border radius to last row + if (arrProposals.length - 1 == i) { + domStatus.classList.add('bblr-7p'); + } + + // Net Yes calculation + const { Yeas, Nays } = cProposal; + const nNetYes = Yeas - Nays; + const nNetYesPercent = (nNetYes / cMasternodes.enabled) * 100; + + // Proposal Status calculation + const nRequiredVotes = Math.round(cMasternodes.enabled * 0.1); + const strStatus = nNetYes >= nRequiredVotes ? 'PASSING' : 'FAILING'; + let strFundingStatus = 'NOT FUNDED'; + + // Funding Status and allocation calculations + if (cProposal.local) { + // Check the finalisation cache + const cPropCache = addProposalToFinalisationCache(cProposal); + if (!cPropCache.fFetching) { + waitForSubmissionBlockHeight(cPropCache).then( + updateGovernanceTab + ); + } + const strStatus = getProposalFinalisationStatus(cPropCache); + const finalizeButton = document.createElement('button'); + finalizeButton.className = 'pivx-button-small'; + finalizeButton.innerHTML = ''; + + if ( + strStatus === 'Ready to submit' || + strStatus === 'Proposal Expired' + ) { + finalizeButton.addEventListener('click', async () => { + const result = await Masternode.finalizeProposal( + cProposal.mpw + ); + const deleteProposal = async () => { + // Remove local Proposal from local storage + const account = await database.getAccount(); + const localProposals = account?.localProposals || []; + await database.addAccount({ + localProposals: localProposals.filter( + (p) => p.txId !== cProposal.mpw.txId + ), + }); + }; + if (result.ok) { + createAlert('success', 'Proposal finalized!'); + deleteProposal(); + updateGovernanceTab(); + } else { + if (result.err === 'unconfirmed') { + createAlert( + 'warning', + "The proposal hasn't been confirmed yet.", + 5000 + ); + } else if (result.err === 'invalid') { + createAlert( + 'warning', + 'The proposal has expired. Create a new one.', + 5000 + ); + deleteProposal(); + updateGovernanceTab(); + } else { + createAlert( + 'warning', + 'Failed to finalize proposal.' + ); + } + } + }); + } else { + finalizeButton.style.opacity = 0.5; + finalizeButton.style.cursor = 'default'; + } + + domStatus.innerHTML = ` + + ${strStatus}
+
+ + + `; + domStatus.appendChild(finalizeButton); + } else { + if (domTable.id == 'proposalsTableBody') { + if ( + nNetYes >= nRequiredVotes && + totalAllocatedAmount + cProposal.MonthlyPayment <= + cChainParams.current.maxPayment / COIN + ) { + // Not enough budget or Net Yes votes for this proposal :( + strFundingStatus = 'FUNDED'; + totalAllocatedAmount += cProposal.MonthlyPayment; + } + } + + domStatus.innerHTML = ` + + ${strStatus}
+ (${strFundingStatus})
+
+ + ${nNetYesPercent.toFixed(1)}%
+ Net Yes +
+ + + `; + } + // Name and URL hyperlink const domNameAndURL = domRow.insertCell(); + // IMPORTANT: Sanitise all of our HTML or a rogue server or malicious proposal could perform a cross-site scripting attack - domNameAndURL.innerHTML = `${sanitizeHTML( cProposal.Name - )}`; + )} `; + + // Convert proposal amount to user's currency + const nProposalValue = parseInt(cProposal.MonthlyPayment) * nPrice; + const { nValue } = optimiseCurrencyLocale(nProposalValue); + const strProposalCurrency = nValue.toLocaleString('en-gb', cLocale); // Payment Schedule and Amounts const domPayments = domRow.insertCell(); - domPayments.innerHTML = `${sanitizeHTML( - cProposal.MonthlyPayment - )} ${cChainParams.current.TICKER}
- ${sanitizeHTML( - cProposal['RemainingPaymentCount'] - )} payments remaining of ${sanitizeHTML(cProposal.TotalPayment)} ${ + domPayments.classList.add('for-desktop'); + domPayments.innerHTML = `${sanitizeHTML( + parseInt(cProposal.MonthlyPayment).toLocaleString('en-gb', ',', '.') + )} ${ cChainParams.current.TICKER - } total`; + }
+ ${strProposalCurrency} ${strCurrency.toUpperCase()} + + ${sanitizeHTML( + cProposal['RemainingPaymentCount'] + )} installment(s) remaining
of ${sanitizeHTML( + parseInt(cProposal.TotalPayment).toLocaleString('en-gb', ',', '.') + )} ${cChainParams.current.TICKER} total
`; // Vote Counts and Consensus Percentages const domVoteCounters = domRow.insertCell(); - const { Yeas, Nays } = cProposal; - const nPercent = cProposal.Ratio * 100; - - domVoteCounters.innerHTML = `${nPercent.toFixed(2)}%
-
${sanitizeHTML( - Yeas - )}
/ -
${sanitizeHTML( - Nays - )}
- `; + domVoteCounters.classList.add('for-desktop'); + + const nLocalPercent = cProposal.Ratio * 100; + domVoteCounters.innerHTML = `${parseFloat( + nLocalPercent + ).toLocaleString( + 'en-gb', + { minimumFractionDigits: 0, maximumFractionDigits: 1 }, + ',', + '.' + )}%
+
${sanitizeHTML( + Yeas + )}
/ +
${sanitizeHTML( + Nays + )}
+ `; // Voting Buttons for Masternode owners (MNOs) + let voteBtn; if (cProposal.local) { - domRow.insertCell(); // Yes/no missing button - const finalizeRow = domRow.insertCell(); - const finalizeButton = document.createElement('button'); - finalizeButton.className = 'pivx-button-small'; - finalizeButton.innerHTML = ''; - finalizeButton.onclick = async () => { - const result = await Masternode.finalizeProposal(cProposal.mpw); - const deleteProposal = async () => { - // Remove local Proposal from local storage - const database = await Database.getInstance(); - const account = await database.getAccount(); - const localProposals = account?.localProposals || []; - await database.addAccount({ - localProposals: localProposals.filter( - (p) => p.txId !== cProposal.mpw.txId - ), - }); - }; - if (result.ok) { - createAlert('success', 'Proposal finalized!'); - deleteProposal(); - updateGovernanceTab(); - } else { - if (result.err === 'unconfirmed') { - createAlert( - 'warning', - "The proposal hasn't been confirmed yet.", - 5000 - ); - } else if (result.err === 'invalid') { - createAlert( - 'warning', - 'The proposal is no longer valid. Create a new one.', - 5000 - ); - deleteProposal(); - updateGovernanceTab(); - } else { - createAlert('warning', 'Failed to finalize proposal.'); - } - } - }; - finalizeRow.appendChild(finalizeButton); + const domVoteBtns = domRow.insertCell(); + domVoteBtns.classList.add('for-desktop'); + voteBtn = ''; } else { - let btnYesClass = 'pivx-button-big'; - let btnNoClass = 'pivx-button-big'; + let btnYesClass = 'pivx-button-small'; + let btnNoClass = 'pivx-button-small'; if (cProposal.YourVote) { if (cProposal.YourVote === 1) { btnYesClass += ' pivx-button-big-yes-gov'; @@ -1923,11 +2225,112 @@ async function renderProposals(arrProposals, fContested) { domYesBtn.innerText = 'Yes'; domYesBtn.onclick = () => govVote(cProposal.Hash, 1); + // Add border radius to last row + if (arrProposals.length - 1 == i) { + domVoteBtns.classList.add('bbrr-7p'); + } + + domVoteBtns.classList.add('for-desktop'); domVoteBtns.appendChild(domNoBtn); domVoteBtns.appendChild(domYesBtn); - domRow.insertCell(); // Finalize proposal missing button + domNoBtn.setAttribute( + 'onclick', + `MPW.govVote('${cProposal.Hash}', 2)` + ); + domYesBtn.setAttribute( + 'onclick', + `MPW.govVote('${cProposal.Hash}', 1);` + ); + voteBtn = domNoBtn.outerHTML + domYesBtn.outerHTML; } + + // Create extended row for mobile + const mobileDomRow = domTable.insertRow(); + const mobileExtended = mobileDomRow.insertCell(); + if (domTable.id == 'proposalsTableBody') { + mobileExtended.id = `governMob${i}`; + } else if (domTable.id == 'proposalsContestedTableBody') { + mobileExtended.id = `governMobCon${i}`; + } + mobileExtended.colSpan = '2'; + mobileExtended.classList.add('text-left'); + mobileExtended.classList.add('d-none'); + mobileExtended.classList.add('for-mobile'); + mobileExtended.innerHTML = ` +
+
+
PAYMENT +
+
+ ${sanitizeHTML( + parseInt(cProposal.MonthlyPayment).toLocaleString( + 'en-gb', + ',', + '.' + ) + )} ${ + cChainParams.current.TICKER + } ${strProposalCurrency} + + ${sanitizeHTML( + cProposal['RemainingPaymentCount'] + )} installment(s) remaining
of ${sanitizeHTML( + parseInt(cProposal.TotalPayment).toLocaleString('en-gb', ',', '.') + )} ${cChainParams.current.TICKER} total
+
+
+
+
+
+
VOTES +
+
+ ${parseFloat(nLocalPercent).toLocaleString( + 'en-gb', + { minimumFractionDigits: 0, maximumFractionDigits: 1 }, + ',', + '.' + )}% +
${sanitizeHTML( + Yeas + )}
/ +
${sanitizeHTML( + Nays + )}
+
+
+
+
+
+
VOTE +
+
+ ${voteBtn} +
+
`; + + i++; + } + + // Show allocated budget + if (domTable.id == 'proposalsTableBody') { + const strAlloc = sanitizeHTML( + totalAllocatedAmount.toLocaleString('en-gb') + ); + doms.domAllocatedGovernanceBudget.innerHTML = strAlloc; + doms.domAllocatedGovernanceBudget2.innerHTML = strAlloc; + + // Update allocated budget in user's currency + const nCurrencyValue = totalAllocatedAmount * nPrice; + const { nValue } = optimiseCurrencyLocale(nCurrencyValue); + const strAllocCurrency = + nValue.toLocaleString('en-gb', cLocale) + + ' ' + + strCurrency.toUpperCase() + + ''; + doms.domAllocatedGovernanceBudgetValue.innerHTML = strAllocCurrency; + doms.domAllocatedGovernanceBudgetValue2.innerHTML = strAllocCurrency; } } @@ -2144,7 +2547,8 @@ export async function createProposal() { if (getBalance() * COIN < cChainParams.current.proposalFee) { return createAlert('warning', 'Not enough funds to create a proposal.'); } - await confirmPopup({ + + const fConfirmed = await confirmPopup({ title: `Create Proposal (cost ${ cChainParams.current.proposalFee / COIN } ${cChainParams.current.TICKER})`, @@ -2153,6 +2557,10 @@ export async function createProposal() {

`, }); + + // If the user cancelled, then we return + if (!fConfirmed) return; + const strTitle = document.getElementById('proposalTitle').value; const strUrl = document.getElementById('proposalUrl').value; const numCycles = parseInt(document.getElementById('proposalCycles').value); @@ -2210,6 +2618,11 @@ export function refreshChainData() { cNet.getBlockCount().then((_) => { // Fetch latest Activity updateActivityGUI(false, true); + + // If it's open: update the Governance Dashboard + if (doms.domGovTab.classList.contains('active')) { + updateGovernanceTab(); + } }); getBalance(true); } diff --git a/scripts/index.js b/scripts/index.js index d5128bf59..b402c1718 100644 --- a/scripts/index.js +++ b/scripts/index.js @@ -43,6 +43,7 @@ export { createProposal, switchSettings, updateActivityGUI, + govVote, } from './global.js'; export { generateWallet, getNewAddress, importWallet } from './wallet.js'; export { toggleTestnet, toggleDebug, toggleAutoSwitch } from './settings.js'; @@ -69,3 +70,5 @@ export { Masternode }; export { getNetwork } from './network.js'; const toggleNetwork = () => getNetwork().toggle(); export { toggleNetwork }; + +export { FlipDown } from './flipdown.js'; diff --git a/scripts/masternode.js b/scripts/masternode.js index 7d4894f6d..24b99b211 100644 --- a/scripts/masternode.js +++ b/scripts/masternode.js @@ -511,7 +511,10 @@ export default class Masternode { res.includes('requires at least') ) { return { ok: false, err: 'unconfirmed' }; - } else if (res.includes('invalid budget proposal')) { + } else if ( + res.includes('invalid budget proposal') || + res.includes('Invalid block start') + ) { return { ok: false, err: 'invalid' }; } else { return { ok: false, err: 'other' }; @@ -528,6 +531,14 @@ export default class Masternode { ); } + /** + * Fetches the masternode count object, containing each status and network. + * @returns {Promise<{total:number, stable:number, enabled:number, inqueue:number, ipv4:number, ipv6:number, onion:number}>} - The masternode count object + */ + static async getMasternodeCount() { + return await (await fetch(`${cNode.url}/getmasternodecount`)).json(); + } + /** * @param {Object} options * @param {String} options.name - Name of the proposal diff --git a/scripts/settings.js b/scripts/settings.js index ee4e117ff..746390afb 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -4,6 +4,7 @@ import { getStakingBalance, refreshChainData, updateActivityGUI, + updateGovernanceTab, } from './global.js'; import { fWalletLoaded, masterKey } from './wallet.js'; import { cChainParams } from './chain_params.js'; @@ -407,6 +408,7 @@ export function toggleTestnet() { getBalance(true); getStakingBalance(true); updateActivityGUI(); + updateGovernanceTab(); } export function toggleDebug() { From 4c89ecd31fd1192b3f0bd87c61478748231682f6 Mon Sep 17 00:00:00 2001 From: BreadJS <83626012+BreadJS@users.noreply.github.com> Date: Mon, 10 Jul 2023 13:33:50 +0200 Subject: [PATCH 2/3] [HTML] Fix password modal on front (#151) --- index.template.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.template.html b/index.template.html index b56a28ab4..1d898f865 100644 --- a/index.template.html +++ b/index.template.html @@ -165,7 +165,7 @@
-