/* globals windowMixin, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, Chart, decryptLnurlPayAES */ Vue.component(VueQrcode.name, VueQrcode) Vue.use(VueQrcodeReader) function generateChart(canvas, rawData) { const data = rawData.reduce( (previous, current) => { previous.labels.push(current.date) previous.income.push(current.income) previous.spending.push(current.spending) previous.cumulative.push(current.balance) return previous }, { labels: [], income: [], spending: [], cumulative: [] } ) return new Chart(canvas.getContext('2d'), { type: 'bar', data: { labels: data.labels, datasets: [ { data: data.cumulative, type: 'line', label: 'balance', backgroundColor: '#673ab7', // deep-purple borderColor: '#673ab7', borderWidth: 4, pointRadius: 3, fill: false }, { data: data.income, type: 'bar', label: 'in', barPercentage: 0.75, backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green }, { data: data.spending, type: 'bar', label: 'out', barPercentage: 0.75, backgroundColor: window.Color('rgb(233,30,99)').alpha(0.5).rgbString() // pink } ] }, options: { title: { text: 'Chart.js Combo Time Scale' }, tooltips: { mode: 'index', intersect: false }, scales: { xAxes: [ { type: 'time', display: true, //offset: true, time: { minUnit: 'hour', stepSize: 3 } } ] }, // performance tweaks animation: { duration: 0 }, elements: { line: { tension: 0 } } } }) } new Vue({ el: '#vue', mixins: [windowMixin], data: function () { return { origin: window.location.origin, user: LNbits.map.user(window.user), receive: { show: false, status: 'pending', paymentReq: null, paymentHash: null, minMax: [0, 2100000000000000], lnurl: null, units: ['sat'], unit: 'sat', data: { amount: null, memo: '' } }, parse: { show: false, invoice: null, lnurlpay: null, lnurlauth: null, data: { request: '', amount: 0, comment: '', unit: 'sat' }, paymentChecker: null, copy: { show: false }, camera: { show: false, camera: 'auto' } }, payments: [], paymentsTable: { columns: [ { name: 'time', align: 'left', label: this.$t('memo') + '/' + this.$t('date'), field: 'date', sortable: true }, { name: 'amount', align: 'right', label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')', field: 'sat', sortable: true } ], pagination: { rowsPerPage: 10, page: 1, sortBy: 'time', descending: true, rowsNumber: 10 }, search: null, loading: false }, paymentsCSV: { columns: [ { name: 'pending', align: 'left', label: 'Pending', field: 'pending' }, { name: 'memo', align: 'left', label: this.$t('memo'), field: 'memo' }, { name: 'time', align: 'left', label: this.$t('date'), field: 'date', sortable: true }, { name: 'amount', align: 'right', label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')', field: 'sat', sortable: true }, { name: 'fee', align: 'right', label: this.$t('fee') + ' (m' + LNBITS_DENOMINATION + ')', field: 'fee' }, { name: 'tag', align: 'right', label: this.$t('tag'), field: 'tag' }, { name: 'payment_hash', align: 'right', label: this.$t('payment_hash'), field: 'payment_hash' }, { name: 'payment_proof', align: 'right', label: this.$t('payment_proof'), field: 'payment_proof' }, { name: 'webhook', align: 'right', label: this.$t('webhook'), field: 'webhook' }, { name: 'fiat_currency', align: 'right', label: 'Fiat Currency', field: row => row.extra.wallet_fiat_currency }, { name: 'fiat_amount', align: 'right', label: 'Fiat Amount', field: row => row.extra.wallet_fiat_amount } ], filter: null, loading: false }, paymentsChart: { show: false, group: {value: 'hour', label: 'Hour'}, groupOptions: [ {value: 'month', label: 'Month'}, {value: 'day', label: 'Day'}, {value: 'hour', label: 'Hour'} ], instance: null }, disclaimerDialog: { show: false, location: window.location }, balance: 0, fiatBalance: 0, mobileSimple: false, credit: 0, update: { name: null, currency: null } } }, computed: { formattedBalance: function () { if (LNBITS_DENOMINATION != 'sats') { return this.balance / 100 } else { return LNbits.utils.formatSat(this.balance || this.g.wallet.sat) } }, formattedFiatBalance() { if (this.fiatBalance) { return LNbits.utils.formatCurrency( this.fiatBalance.toFixed(2), this.g.wallet.currency ) } }, filteredPayments: function () { var q = this.paymentsTable.search if (!q || q === '') return this.payments return LNbits.utils.search(this.payments, q) }, paymentsOmitter() { if (this.$q.screen.lt.md && this.mobileSimple) { return this.payments.length > 0 ? [this.payments[0]] : [] } return this.payments }, canPay: function () { if (!this.parse.invoice) return false return this.parse.invoice.sat <= this.balance }, pendingPaymentsExist: function () { return this.payments.findIndex(payment => payment.pending) !== -1 } }, methods: { msatoshiFormat: function (value) { return LNbits.utils.formatSat(value / 1000) }, paymentTableRowKey: function (row) { return row.payment_hash + row.amount }, closeCamera: function () { this.parse.camera.show = false }, showCamera: function () { this.parse.camera.show = true }, showChart: function () { this.paymentsChart.show = true LNbits.api .request( 'GET', '/api/v1/payments/history?group=' + this.paymentsChart.group.value, this.g.wallet.adminkey ) .then(response => { this.$nextTick(() => { if (this.paymentsChart.instance) { this.paymentsChart.instance.destroy() } this.paymentsChart.instance = generateChart( this.$refs.canvas, response.data ) }) }) .catch(err => { LNbits.utils.notifyApiError(err) this.paymentsChart.show = false }) }, focusInput(el) { this.$nextTick(() => this.$refs[el].focus()) }, showReceiveDialog: function () { this.receive.show = true this.receive.status = 'pending' this.receive.paymentReq = null this.receive.paymentHash = null this.receive.data.amount = null this.receive.data.memo = null this.receive.unit = 'sat' this.receive.minMax = [0, 2100000000000000] this.receive.lnurl = null this.focusInput('setAmount') }, showParseDialog: function () { this.parse.show = true this.parse.invoice = null this.parse.lnurlpay = null this.parse.lnurlauth = null this.parse.copy.show = window.isSecureContext && navigator.clipboard?.readText !== undefined this.parse.data.request = '' this.parse.data.comment = '' this.parse.data.paymentChecker = null this.parse.camera.show = false this.focusInput('textArea') }, closeParseDialog: function () { setTimeout(() => { clearInterval(this.parse.paymentChecker) }, 10000) }, onPaymentReceived: function (paymentHash) { this.fetchPayments() if (this.receive.paymentHash === paymentHash) { this.receive.show = false this.receive.paymentHash = null } }, createInvoice: function () { this.receive.status = 'loading' if (LNBITS_DENOMINATION != 'sats') { this.receive.data.amount = this.receive.data.amount * 100 } LNbits.api .createInvoice( this.g.wallet, this.receive.data.amount, this.receive.data.memo, this.receive.unit, this.receive.lnurl && this.receive.lnurl.callback ) .then(response => { this.receive.status = 'success' this.receive.paymentReq = response.data.payment_request this.receive.paymentHash = response.data.payment_hash if (response.data.lnurl_response !== null) { if (response.data.lnurl_response === false) { response.data.lnurl_response = `Unable to connect` } if (typeof response.data.lnurl_response === 'string') { // failure this.$q.notify({ timeout: 5000, type: 'warning', message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`, caption: response.data.lnurl_response }) return } else if (response.data.lnurl_response === true) { // success this.$q.notify({ timeout: 5000, message: `Invoice sent to ${this.receive.lnurl.domain}!`, spinner: true }) } } this.fetchPayments() }) .catch(err => { LNbits.utils.notifyApiError(err) this.receive.status = 'pending' }) }, onInitQR: async function (promise) { try { await promise } catch (error) { let mapping = { NotAllowedError: 'ERROR: you need to grant camera access permission', NotFoundError: 'ERROR: no camera on this device', NotSupportedError: 'ERROR: secure context required (HTTPS, localhost)', NotReadableError: 'ERROR: is the camera already in use?', OverconstrainedError: 'ERROR: installed cameras are not suitable', StreamApiNotSupportedError: 'ERROR: Stream API is not supported in this browser', InsecureContextError: 'ERROR: Camera access is only permitted in secure context. Use HTTPS or localhost rather than HTTP.' } let valid_error = Object.keys(mapping).filter(key => { return error.name === key }) let camera_error = valid_error ? mapping[valid_error] : `ERROR: Camera error (${error.name})` this.parse.camera.show = false this.$q.notify({ message: camera_error, type: 'negative' }) } }, decodeQR: function (res) { this.parse.data.request = res this.decodeRequest() this.parse.camera.show = false }, decodeRequest: function () { this.parse.show = true let req = this.parse.data.request.toLowerCase() if (this.parse.data.request.toLowerCase().startsWith('lightning:')) { this.parse.data.request = this.parse.data.request.slice(10) } else if (this.parse.data.request.toLowerCase().startsWith('lnurl:')) { this.parse.data.request = this.parse.data.request.slice(6) } else if (req.indexOf('lightning=lnurl1') !== -1) { this.parse.data.request = this.parse.data.request .split('lightning=')[1] .split('&')[0] } if ( this.parse.data.request.toLowerCase().startsWith('lnurl1') || this.parse.data.request.match(/[\w.+-~_]+@[\w.+-~_]/) ) { LNbits.api .request( 'GET', '/api/v1/lnurlscan/' + this.parse.data.request, this.g.wallet.adminkey ) .catch(err => { LNbits.utils.notifyApiError(err) }) .then(response => { let data = response.data if (data.status === 'ERROR') { this.$q.notify({ timeout: 5000, type: 'warning', message: `${data.domain} lnurl call failed.`, caption: data.reason }) return } if (data.kind === 'pay') { this.parse.lnurlpay = Object.freeze(data) this.parse.data.amount = data.minSendable / 1000 } else if (data.kind === 'auth') { this.parse.lnurlauth = Object.freeze(data) } else if (data.kind === 'withdraw') { this.parse.show = false this.receive.show = true this.receive.status = 'pending' this.receive.paymentReq = null this.receive.paymentHash = null this.receive.data.amount = data.maxWithdrawable / 1000 this.receive.data.memo = data.defaultDescription this.receive.minMax = [ data.minWithdrawable / 1000, data.maxWithdrawable / 1000 ] this.receive.lnurl = { domain: data.domain, callback: data.callback, fixed: data.fixed } } }) return } // BIP-21 support if (this.parse.data.request.toLowerCase().includes('lightning')) { this.parse.data.request = this.parse.data.request.split('lightning=')[1] // fail safe to check there's nothing after the lightning= part if (this.parse.data.request.includes('&')) { this.parse.data.request = this.parse.data.request.split('&')[0] } } let invoice try { invoice = decode(this.parse.data.request) } catch (error) { this.$q.notify({ timeout: 3000, type: 'warning', message: error + '.', caption: '400 BAD REQUEST' }) this.parse.show = false return } let cleanInvoice = { msat: invoice.human_readable_part.amount, sat: invoice.human_readable_part.amount / 1000, fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000) } _.each(invoice.data.tags, tag => { if (_.isObject(tag) && _.has(tag, 'description')) { if (tag.description === 'payment_hash') { cleanInvoice.hash = tag.value } else if (tag.description === 'description') { cleanInvoice.description = tag.value } else if (tag.description === 'expiry') { var expireDate = new Date( (invoice.data.time_stamp + tag.value) * 1000 ) cleanInvoice.expireDate = Quasar.utils.date.formatDate( expireDate, 'YYYY-MM-DDTHH:mm:ss.SSSZ' ) cleanInvoice.expired = false // TODO } } }) this.parse.invoice = Object.freeze(cleanInvoice) }, payInvoice: function () { let dismissPaymentMsg = this.$q.notify({ timeout: 0, message: this.$t('processing_payment') }) LNbits.api .payInvoice(this.g.wallet, this.parse.data.request) .then(response => { clearInterval(this.parse.paymentChecker) setTimeout(() => { clearInterval(this.parse.paymentChecker) }, 40000) this.parse.paymentChecker = setInterval(() => { LNbits.api .getPayment(this.g.wallet, response.data.payment_hash) .then(res => { if (res.data.paid) { this.parse.show = false clearInterval(this.parse.paymentChecker) dismissPaymentMsg() this.fetchPayments() } }) }, 2000) }) .catch(err => { dismissPaymentMsg() LNbits.utils.notifyApiError(err) }) }, payLnurl: function () { let dismissPaymentMsg = this.$q.notify({ timeout: 0, message: 'Processing payment...' }) LNbits.api .payLnurl( this.g.wallet, this.parse.lnurlpay.callback, this.parse.lnurlpay.description_hash, this.parse.data.amount * 1000, this.parse.lnurlpay.description.slice(0, 120), this.parse.data.comment, this.parse.data.unit ) .then(response => { this.parse.show = false clearInterval(this.parse.paymentChecker) setTimeout(() => { clearInterval(this.parse.paymentChecker) }, 40000) this.parse.paymentChecker = setInterval(() => { LNbits.api .getPayment(this.g.wallet, response.data.payment_hash) .then(res => { if (res.data.paid) { dismissPaymentMsg() clearInterval(this.parse.paymentChecker) this.fetchPayments() // show lnurlpay success action if (response.data.success_action) { switch (response.data.success_action.tag) { case 'url': this.$q.notify({ message: `${response.data.success_action.url}`, caption: response.data.success_action.description, html: true, type: 'positive', timeout: 0, closeBtn: true }) break case 'message': this.$q.notify({ message: response.data.success_action.message, type: 'positive', timeout: 0, closeBtn: true }) break case 'aes': LNbits.api .getPayment(this.g.wallet, response.data.payment_hash) .then(({data: payment}) => decryptLnurlPayAES( response.data.success_action, payment.preimage ) ) .then(value => { this.$q.notify({ message: value, caption: response.data.success_action.description, html: true, type: 'positive', timeout: 0, closeBtn: true }) }) break } } } }) }, 2000) }) .catch(err => { dismissPaymentMsg() LNbits.utils.notifyApiError(err) }) }, authLnurl: function () { let dismissAuthMsg = this.$q.notify({ timeout: 10, message: 'Performing authentication...' }) LNbits.api .authLnurl(this.g.wallet, this.parse.lnurlauth.callback) .then(_ => { dismissAuthMsg() this.$q.notify({ message: `Authentication successful.`, type: 'positive', timeout: 3500 }) this.parse.show = false }) .catch(err => { dismissAuthMsg() if (err.response.data.reason) { this.$q.notify({ message: `Authentication failed. ${this.parse.lnurlauth.domain} says:`, caption: err.response.data.reason, type: 'warning', timeout: 5000 }) } else { LNbits.utils.notifyApiError(err) } }) }, updateWallet: function (data) { LNbits.api .request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data) .then(_ => { this.$q.notify({ message: `Wallet updated.`, type: 'positive', timeout: 3500 }) window.location.reload() }) .catch(err => { LNbits.utils.notifyApiError(err) }) }, deleteWallet: function () { LNbits.utils .confirmDialog('Are you sure you want to delete this wallet?') .onOk(() => { LNbits.api .deleteWallet(this.g.wallet) .then(_ => { this.$q.notify({ timeout: 3000, message: `Wallet deleted!`, spinner: true }) }) .catch(err => { this.paymentsTable.loading = false LNbits.utils.notifyApiError(err) }) }) }, fetchPayments: function (props) { const params = LNbits.utils.prepareFilterQuery(this.paymentsTable, props) return LNbits.api .getPayments(this.g.wallet, params) .then(response => { this.paymentsTable.loading = false this.paymentsTable.pagination.rowsNumber = response.data.total this.payments = response.data.data.map(obj => { return LNbits.map.payment(obj) }) }) .catch(err => { this.paymentsTable.loading = false LNbits.utils.notifyApiError(err) }) }, fetchBalance: function () { LNbits.api.getWallet(this.g.wallet).then(response => { this.balance = Math.floor(response.data.balance / 1000) EventHub.$emit('update-wallet-balance', [ this.g.wallet.id, this.balance ]) }) if (this.g.wallet.currency) { this.updateFiatBalance() } }, updateFiatBalance() { if (!this.g.wallet.currency) return 0 LNbits.api .request('POST', `/api/v1/conversion`, null, { amount: this.balance || this.g.wallet.sat, to: this.g.wallet.currency }) .then(response => { this.fiatBalance = response.data[this.g.wallet.currency] }) .catch(e => console.error(e)) }, formatFiat(currency, amount) { return LNbits.utils.formatCurrency(amount, currency) }, updateBalanceCallback: function (res) { this.balance += res.value }, exportCSV: function () { // status is important for export but it is not in paymentsTable // because it is manually added with payment detail link and icons // and would cause duplication in the list const pagination = this.paymentsTable.pagination const query = { sortby: pagination.sortBy ?? 'time', direction: pagination.descending ? 'desc' : 'asc' } const params = new URLSearchParams(query) LNbits.api.getPayments(this.g.wallet, params).then(response => { const payments = response.data.data.map(LNbits.map.payment) LNbits.utils.exportCSV( this.paymentsCSV.columns, payments, this.g.wallet.name + '-payments' ) }) }, pasteToTextArea: function () { this.$refs.textArea.focus() // Set cursor to textarea navigator.clipboard.readText().then(text => { this.$refs.textArea.value = text }) } }, watch: { payments: function () { this.fetchBalance() }, 'paymentsChart.group': function () { this.showChart() } }, created: function () { let urlParams = new URLSearchParams(window.location.search) if (urlParams.has('lightning') || urlParams.has('lnurl')) { this.parse.data.request = urlParams.get('lightning') || urlParams.get('lnurl') this.decodeRequest() this.parse.show = true } if (this.$q.screen.lt.md) { this.mobileSimple = true } this.fetchPayments() this.update.name = this.g.wallet.name this.update.currency = this.g.wallet.currency this.receive.units = ['sat', ...window.currencies] }, mounted: function () { // show disclaimer if (!this.$q.localStorage.getItem('lnbits.disclaimerShown')) { this.disclaimerDialog.show = true this.$q.localStorage.set('lnbits.disclaimerShown', true) } // listen to incoming payments LNbits.events.onInvoicePaid(this.g.wallet, payment => this.onPaymentReceived(payment.payment_hash) ) eventReactionWebocket(wallet.id) } }) if (navigator.serviceWorker != null) { navigator.serviceWorker .register('/service-worker.js') .then(function (registration) { console.log('Registered events at scope: ', registration.scope) }) }