574 lines
19 KiB
HTML
574 lines
19 KiB
HTML
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
|
%} {% block page %} {% raw %}
|
|
<div class="row q-col-gutter-md">
|
|
<div class="col-12 col-md-7 q-gutter-y-md">
|
|
<q-card>
|
|
<q-form @submit="addRelay">
|
|
<div class="row">
|
|
<div class="col-12 col-md-7 q-pa-md">
|
|
<q-input outlined v-model="relayToAdd" dense filled label="Relay URL"></q-input>
|
|
</div>
|
|
<div class="col-6 col-md-3 q-pa-md">
|
|
<q-btn-dropdown unelevated split color="primary" class="float-left" type="submit" label="Add Relay">
|
|
<q-item v-for="relay in predefinedRelays" :key="relay" @click="addCustomRelay(relay)" clickable
|
|
v-close-popup>
|
|
<q-item-section>
|
|
<q-item-label><span v-text="relay"></span></q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-btn-dropdown>
|
|
</div>
|
|
<div class="col-6 col-md-2 q-pa-md">
|
|
<q-btn unelevated @click="config.showDialog = true" color="primary" icon="settings"
|
|
class="float-right"></q-btn>
|
|
</div>
|
|
</div>
|
|
</q-form>
|
|
</q-card>
|
|
<q-card>
|
|
<q-card-section>
|
|
<div class="row items-center no-wrap q-mb-md">
|
|
<div class="col">
|
|
<h5 class="text-subtitle1 q-my-none">Nostrclient</h5>
|
|
</div>
|
|
<div class="col-auto">
|
|
<q-input borderless dense debounce="300" v-model="filter" placeholder="Search">
|
|
<template v-slot:append>
|
|
<q-icon name="search"></q-icon>
|
|
</template>
|
|
</q-input>
|
|
</div>
|
|
</div>
|
|
<q-table flat dense :data="nostrrelayLinks" row-key="id" :columns="relayTable.columns"
|
|
:pagination.sync="relayTable.pagination" :filter="filter">
|
|
<template v-slot:header="props">
|
|
<q-tr :props="props">
|
|
<q-th v-for="col in props.cols" :key="col.name" :props="props" auto-width>
|
|
<div v-if="col.name == 'id'"></div>
|
|
<div v-else>{{ col.label }}</div>
|
|
</q-th>
|
|
</q-tr>
|
|
</template>
|
|
|
|
<template v-slot:body="props">
|
|
<q-tr :props="props">
|
|
<q-td v-for="col in props.cols" :key="col.name" :props="props" auto-width>
|
|
<div v-if="col.name == 'connected'">
|
|
<div v-if="col.value">🟢</div>
|
|
<div v-else>🔴</div>
|
|
</div>
|
|
<div v-else-if="col.name == 'status'">
|
|
<div>
|
|
⬆️ <span v-text="col.value.sentEvents"></span> ⬇️
|
|
<span v-text="col.value.receveidEvents"></span>
|
|
<span @click="showLogDataDialog(col.value.errorList)" class="cursor-pointer">
|
|
⚠️ <span v-text="col.value.errorCount"> </span>
|
|
</span>
|
|
<span @click="showLogDataDialog(col.value.noticeList)" class="cursor-pointer float-right">
|
|
ⓘ
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div v-else-if="col.name == 'delete'">
|
|
<q-btn flat dense size="md" @click="showDeleteRelayDialog(props.row.url)" icon="cancel"
|
|
color="pink"></q-btn>
|
|
</div>
|
|
<div v-else>
|
|
<div>{{ col.value }}</div>
|
|
</div>
|
|
</q-td>
|
|
</q-tr>
|
|
</template>
|
|
</q-table>
|
|
</q-card-section>
|
|
</q-card>
|
|
<q-card>
|
|
<q-card-section>
|
|
<div class="row">
|
|
<div class="col">
|
|
<div class="text-weight-bold">
|
|
<q-btn flat dense size="0.6rem" class="q-px-none q-mx-none" color="grey" icon="content_copy"
|
|
@click="copyText(`wss://${host}/nostrclient/api/v1/relay`)"><q-tooltip>Copy address</q-tooltip></q-btn>
|
|
Your endpoint:
|
|
<q-badge outline class="q-ml-sm text-subtitle2" :label="`wss://${host}/nostrclient/api/v1/relay`" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</q-card-section>
|
|
<q-expansion-item group="advanced" icon="settings" label="Test this endpoint" @click="toggleTestPanel">
|
|
<q-separator></q-separator>
|
|
<q-card-section>
|
|
<div class="row">
|
|
<div class="col-3">
|
|
<span>Sender Private Key:</span>
|
|
</div>
|
|
<div class="col-9">
|
|
<q-input outlined v-model="testData.senderPrivateKey" dense filled
|
|
label="Private Key (optional)"></q-input>
|
|
</div>
|
|
</div>
|
|
<div class="row q-mt-sm q-mb-lg">
|
|
<div class="col-3"></div>
|
|
<div class="col-9">
|
|
<q-badge color="yellow" text-color="black">
|
|
<span>
|
|
No not use your real private key! Leave empty for a randomly
|
|
generated key.</span>
|
|
</q-badge>
|
|
</div>
|
|
</div>
|
|
<div v-if="testData.senderPublicKey" class="row">
|
|
<div class="col-3">
|
|
<span>Sender Public Key:</span>
|
|
</div>
|
|
<div class="col-9">
|
|
<q-input outlined v-model="testData.senderPublicKey" dense readonly filled></q-input>
|
|
</div>
|
|
</div>
|
|
<div class="row q-mt-md">
|
|
<div class="col-3">
|
|
<span>Test Message:</span>
|
|
</div>
|
|
<div class="col-9">
|
|
<q-input outlined v-model="testData.message" dense filled rows="3" type="textarea"
|
|
label="Test Message *"></q-input>
|
|
</div>
|
|
</div>
|
|
<div class="row q-mt-md">
|
|
<div class="col-3">
|
|
<span>Receiver Public Key:</span>
|
|
</div>
|
|
<div class="col-9">
|
|
<q-input outlined v-model="testData.recieverPublicKey" dense filled
|
|
label="Public Key (hex or npub) *"></q-input>
|
|
</div>
|
|
</div>
|
|
<div class="row q-mt-sm q-mb-lg">
|
|
<div class="col-3"></div>
|
|
<div class="col-9">
|
|
<q-badge color="yellow" text-color="black">
|
|
<span>This is the recipient of the message. Field required.</span>
|
|
</q-badge>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<q-btn :disabled="!testData.recieverPublicKey || !testData.message" @click="sendTestMessage" unelevated
|
|
color="primary" class="float-right">Send Message</q-btn>
|
|
</div>
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<q-separator></q-separator>
|
|
<q-card-section>
|
|
<div class="row q-mt-md">
|
|
<div class="col-3">
|
|
<span>Sent Data:</span>
|
|
</div>
|
|
<div class="col-9">
|
|
<q-input outlined v-model="testData.sentData" dense filled rows="5" type="textarea"></q-input>
|
|
</div>
|
|
</div>
|
|
<div class="row q-mt-md">
|
|
<div class="col-3">
|
|
<span>Received Data:</span>
|
|
</div>
|
|
<div class="col-9">
|
|
<q-input outlined v-model="testData.receivedData" dense filled rows="5" type="textarea"></q-input>
|
|
</div>
|
|
</div>
|
|
</q-card-section>
|
|
</q-expansion-item>
|
|
</q-card>
|
|
</div>
|
|
|
|
<div class="col-12 col-md-5 q-gutter-y-md">
|
|
<q-card>
|
|
<q-card-section>
|
|
<h6 class="text-subtitle1 q-my-none">Nostrclient Extension</h6>
|
|
<p>
|
|
This extension is a always-on nostr client that other extensions can
|
|
use to send and receive events on nostr. Add multiple nostr relays to
|
|
connect to. The extension then opens a websocket for you to use at
|
|
</p>
|
|
|
|
<p>
|
|
<q-badge outline class="q-ml-sm text-subtitle2" color="primary"
|
|
:label="`wss://${host}/nostrclient/api/v1/relay`" />
|
|
</p>
|
|
Only Admin users can manage this extension.
|
|
<q-card-section></q-card-section>
|
|
</q-card-section>
|
|
</q-card>
|
|
</div>
|
|
|
|
<q-dialog v-model="logData.show" position="top">
|
|
<q-card class="q-pa-lg q-pt-xl">
|
|
<q-input filled dense v-model.trim="logData.data" type="textarea" rows="25" cols="200" label="Log Data"></q-input>
|
|
|
|
<div class="row q-mt-lg">
|
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
|
</div>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<q-dialog v-model="config.showDialog" position="top">
|
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
|
<q-form @submit="updateConfig" class="q-gutter-md">
|
|
<q-toggle label="Expose Private Websocket" color="secodary" v-model="config.data.private_ws"></q-toggle>
|
|
<br />
|
|
<q-toggle label="Expose Public Websocket" color="secodary" v-model="config.data.public_ws"></q-toggle>
|
|
<div class="row q-mt-lg">
|
|
<q-btn unelevated color="primary" type="submit">Update</q-btn>
|
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
|
</div>
|
|
</q-form>
|
|
</q-card>
|
|
</q-dialog>
|
|
</div>
|
|
{% endraw %} {% endblock %} {% block scripts %} {{ window_vars(user) }}
|
|
|
|
<script>
|
|
Vue.component(VueQrcode.name, VueQrcode)
|
|
|
|
var maplrelays = obj => {
|
|
obj._data = _.clone(obj)
|
|
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
|
|
obj.time = obj.time + 'mins'
|
|
obj.status = {
|
|
sentEvents: obj.status.num_sent_events,
|
|
receveidEvents: obj.status.num_received_events,
|
|
errorCount: obj.status.error_counter,
|
|
errorList: obj.status.error_list,
|
|
noticeList: obj.status.notice_list
|
|
}
|
|
|
|
obj.ping = obj.ping + ' ms'
|
|
|
|
if (obj.time_elapsed) {
|
|
obj.date = 'Time elapsed'
|
|
} else {
|
|
obj.date = Quasar.utils.date.formatDate(
|
|
new Date((obj.theTime - 3600) * 1000),
|
|
'HH:mm:ss'
|
|
)
|
|
}
|
|
return obj
|
|
}
|
|
|
|
new Vue({
|
|
el: '#vue',
|
|
mixins: [windowMixin],
|
|
data: function () {
|
|
return {
|
|
base_url: location.protocol + '//' + location.host,
|
|
host: location.host,
|
|
relayToAdd: '',
|
|
nostrrelayLinks: [],
|
|
filter: '',
|
|
logData: {
|
|
show: false,
|
|
data: null
|
|
},
|
|
config: {
|
|
showDialog: false,
|
|
data: {},
|
|
},
|
|
testData: {
|
|
show: false,
|
|
wsConnection: null,
|
|
senderPrivateKey: null,
|
|
senderPublicKey: null,
|
|
recieverPublicKey: null,
|
|
message: null,
|
|
sentData: '',
|
|
receivedData: ''
|
|
},
|
|
relayTable: {
|
|
columns: [
|
|
{
|
|
name: 'connected',
|
|
align: 'left',
|
|
label: '',
|
|
field: 'connected'
|
|
},
|
|
{
|
|
name: 'relay',
|
|
align: 'left',
|
|
label: 'URL',
|
|
field: 'url'
|
|
},
|
|
{
|
|
name: 'status',
|
|
align: 'center',
|
|
label: 'Status',
|
|
field: 'status'
|
|
},
|
|
{
|
|
name: 'ping',
|
|
align: 'center',
|
|
label: 'Ping',
|
|
field: 'ping'
|
|
},
|
|
{
|
|
name: 'delete',
|
|
align: 'center',
|
|
label: '',
|
|
field: ''
|
|
}
|
|
],
|
|
pagination: {
|
|
rowsPerPage: 10
|
|
}
|
|
},
|
|
predefinedRelays: [
|
|
'wss://relay.damus.io',
|
|
'wss://nostr-pub.wellorder.net',
|
|
'wss://nostr.zebedee.cloud',
|
|
'wss://nodestr.fmt.wiz.biz',
|
|
'wss://nostr.oxtr.dev',
|
|
'wss://nostr.wine'
|
|
]
|
|
}
|
|
},
|
|
methods: {
|
|
getRelays: function () {
|
|
var self = this
|
|
LNbits.api
|
|
.request(
|
|
'GET',
|
|
'/nostrclient/api/v1/relays?usr=' + this.g.user.id,
|
|
this.g.user.wallets[0].adminkey
|
|
)
|
|
.then(function (response) {
|
|
if (response.data) {
|
|
response.data.map(maplrelays)
|
|
self.nostrrelayLinks = response.data
|
|
}
|
|
})
|
|
.catch(function (error) {
|
|
LNbits.utils.notifyApiError(error)
|
|
})
|
|
},
|
|
addRelay() {
|
|
if (
|
|
!this.relayToAdd.startsWith('wss://') &&
|
|
!this.relayToAdd.startsWith('ws://')
|
|
) {
|
|
this.relayToAdd = ''
|
|
this.$q.notify({
|
|
timeout: 5000,
|
|
type: 'warning',
|
|
message: `Invalid relay URL.`,
|
|
caption: "Should start with 'wss://'' or 'ws://'"
|
|
})
|
|
return false
|
|
}
|
|
console.log('ADD RELAY ' + this.relayToAdd)
|
|
let that = this
|
|
LNbits.api
|
|
.request(
|
|
'POST',
|
|
'/nostrclient/api/v1/relay?usr=' + this.g.user.id,
|
|
this.g.user.wallets[0].adminkey,
|
|
{ url: this.relayToAdd }
|
|
)
|
|
.then(function (response) {
|
|
console.log('response:', response)
|
|
if (response.data) {
|
|
response.data.map(maplrelays)
|
|
that.nostrrelayLinks = response.data
|
|
that.relayToAdd = ''
|
|
}
|
|
})
|
|
.catch(function (error) {
|
|
LNbits.utils.notifyApiError(error)
|
|
})
|
|
return false
|
|
},
|
|
async addCustomRelay(relayUrl) {
|
|
this.relayToAdd = relayUrl
|
|
await this.addRelay()
|
|
},
|
|
showDeleteRelayDialog: function (url) {
|
|
LNbits.utils
|
|
.confirmDialog(' Are you sure you want to remove this relay?')
|
|
.onOk(async () => {
|
|
this.deleteRelay(url)
|
|
})
|
|
},
|
|
deleteRelay(url) {
|
|
LNbits.api
|
|
.request(
|
|
'DELETE',
|
|
'/nostrclient/api/v1/relay?usr=' + this.g.user.id,
|
|
this.g.user.wallets[0].adminkey,
|
|
{ url: url }
|
|
)
|
|
.then(response => {
|
|
const relayIndex = this.nostrrelayLinks.indexOf(r => r.url === url)
|
|
if (relayIndex !== -1) {
|
|
this.nostrrelayLinks.splice(relayIndex, 1)
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error(error)
|
|
LNbits.utils.notifyApiError(error)
|
|
})
|
|
},
|
|
getConfig: async function () {
|
|
try {
|
|
const { data } = await LNbits.api
|
|
.request(
|
|
'GET',
|
|
'/nostrclient/api/v1/config',
|
|
this.g.user.wallets[0].adminkey
|
|
)
|
|
this.config.data = data
|
|
} catch (error) {
|
|
LNbits.utils.notifyApiError(error)
|
|
}
|
|
},
|
|
updateConfig: async function () {
|
|
try {
|
|
const { data } = await LNbits.api.request(
|
|
'PUT',
|
|
'/nostrclient/api/v1/config',
|
|
this.g.user.wallets[0].adminkey,
|
|
this.config.data
|
|
)
|
|
this.config.data = data
|
|
|
|
} catch (error) {
|
|
LNbits.utils.notifyApiError(error)
|
|
}
|
|
this.config.showDialog = false
|
|
},
|
|
toggleTestPanel: async function () {
|
|
if (this.testData.show) {
|
|
await this.hideTestPannel()
|
|
} else {
|
|
await this.showTestPanel()
|
|
}
|
|
},
|
|
showTestPanel: async function () {
|
|
this.testData = {
|
|
show: true,
|
|
wsConnection: null,
|
|
senderPrivateKey:
|
|
this.$q.localStorage.getItem(
|
|
'lnbits.nostrclient.senderPrivateKey'
|
|
) || '',
|
|
recieverPublicKey: null,
|
|
message: null,
|
|
sentData: '',
|
|
receivedData: ''
|
|
}
|
|
await this.closeWebsocket()
|
|
this.connectToWebsocket()
|
|
},
|
|
hideTestPannel: async function () {
|
|
await this.closeWebsocket()
|
|
this.testData = {
|
|
show: false,
|
|
wsConnection: null,
|
|
senderPrivateKey: null,
|
|
recieverPublicKey: null,
|
|
message: null,
|
|
sentData: '',
|
|
receivedData: ''
|
|
}
|
|
},
|
|
sendTestMessage: async function () {
|
|
try {
|
|
const { data } = await LNbits.api.request(
|
|
'PUT',
|
|
'/nostrclient/api/v1/relay/test?usr=' + this.g.user.id,
|
|
this.g.user.wallets[0].adminkey,
|
|
{
|
|
sender_private_key: this.testData.senderPrivateKey,
|
|
reciever_public_key: this.testData.recieverPublicKey,
|
|
message: this.testData.message
|
|
}
|
|
)
|
|
this.testData.senderPrivateKey = data.private_key
|
|
this.$q.localStorage.set(
|
|
'lnbits.nostrclient.senderPrivateKey',
|
|
data.private_key || ''
|
|
)
|
|
const event = JSON.parse(data.event_json)[1]
|
|
this.testData.senderPublicKey = event.pubkey
|
|
await this.sendDataToWebSocket(data.event_json)
|
|
const subscription = JSON.stringify([
|
|
'REQ',
|
|
'test-dms',
|
|
{ kinds: [4], '#p': [event.pubkey] }
|
|
])
|
|
this.testData.wsConnection.send(subscription)
|
|
} catch (error) {
|
|
LNbits.utils.notifyApiError(error)
|
|
}
|
|
},
|
|
|
|
sendDataToWebSocket: async function (data) {
|
|
try {
|
|
if (!this.testData.wsConnection) {
|
|
this.connectToWebsocket()
|
|
await this.sleep(500)
|
|
}
|
|
this.testData.wsConnection.send(data)
|
|
const separator = '='.repeat(80)
|
|
this.testData.sentData =
|
|
data + `\n\n${separator}\n` + this.testData.sentData
|
|
} catch (error) {
|
|
this.$q.notify({
|
|
timeout: 5000,
|
|
type: 'warning',
|
|
message: 'Failed to connect to websocket',
|
|
caption: `${error}`
|
|
})
|
|
}
|
|
},
|
|
connectToWebsocket: function () {
|
|
const scheme = location.protocol === 'http:' ? 'ws' : 'wss'
|
|
const port = location.port ? `:${location.port}` : ''
|
|
const wsUrl = `${scheme}://${document.domain}${port}/nostrclient/api/v1/relay`
|
|
this.testData.wsConnection = new WebSocket(wsUrl)
|
|
const updateReciveData = async e => {
|
|
const separator = '='.repeat(80)
|
|
this.testData.receivedData =
|
|
e.data + `\n\n${separator}\n` + this.testData.receivedData
|
|
}
|
|
|
|
this.testData.wsConnection.onmessage = updateReciveData
|
|
this.testData.wsConnection.onerror = updateReciveData
|
|
this.testData.wsConnection.onclose = updateReciveData
|
|
},
|
|
closeWebsocket: async function () {
|
|
try {
|
|
if (this.testData.wsConnection) {
|
|
this.testData.wsConnection.close()
|
|
await this.sleep(100)
|
|
}
|
|
} catch (error) {
|
|
console.warn(error)
|
|
}
|
|
},
|
|
showLogDataDialog: function (data = []) {
|
|
this.logData.data = data.join('\n')
|
|
this.logData.show = true
|
|
},
|
|
exportlnurldeviceCSV: function () {
|
|
var self = this
|
|
LNbits.utils.exportCSV(self.relayTable.columns, this.nostrLinks)
|
|
},
|
|
sleep: ms => new Promise(r => setTimeout(r, ms))
|
|
},
|
|
created: async function () {
|
|
this.getRelays()
|
|
await this.getConfig()
|
|
setInterval(this.getRelays, 5000)
|
|
}
|
|
})
|
|
</script>
|
|
{% endblock %}
|