update: manage jack effect

This commit is contained in:
monica 2022-12-21 15:30:07 -06:00
parent 5b89a12f39
commit 9bf4e0a6a3
31 changed files with 1246 additions and 270 deletions

View File

@ -18,8 +18,10 @@
"codemirror": "5", "codemirror": "5",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"cross-fetch": "^3.1.5", "cross-fetch": "^3.1.5",
"dompurify": "^2.4.1",
"emoji-mart-vue-fast": "^10.2.1", "emoji-mart-vue-fast": "^10.2.1",
"identicon.js": "2.3", "identicon.js": "2.3",
"light-bolt11-decoder": "^2.1.0",
"markdown-it": "13.0", "markdown-it": "13.0",
"markdown-it-deflist": "2.1", "markdown-it-deflist": "2.1",
"markdown-it-emoji": "^2.0.2", "markdown-it-emoji": "^2.0.2",
@ -28,6 +30,7 @@
"markdown-it-sup": "1.0", "markdown-it-sup": "1.0",
"markdown-it-task-lists": "2.1", "markdown-it-task-lists": "2.1",
"mergebounce": "^0.1.1", "mergebounce": "^0.1.1",
"nayuki-qr-code-generator": "^1.8.0",
"nostr-tools": "^0.24.1", "nostr-tools": "^0.24.1",
"quasar": "2.5.5", "quasar": "2.5.5",
"readable-stream": "3.6.0", "readable-stream": "3.6.0",

View File

@ -9,7 +9,7 @@
:size='buttonSize' :size='buttonSize'
class='button-copy' class='button-copy'
dense dense
:label='verbose ? "copy" : ""' :label='(verbose || buttonLabel) ? (buttonLabel || "copy") : ""'
align="left" align="left"
> >
<q-tooltip v-if='tooltipText'> <q-tooltip v-if='tooltipText'>
@ -20,14 +20,21 @@
<script> <script>
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import helpersMixin from '../utils/mixin'
import {Notify} from 'quasar'
export default defineComponent({ export default defineComponent({
name: 'BaseButtonCopy', name: 'BaseButtonCopy',
mixins: [helpersMixin],
props: { props: {
buttonText: { buttonText: {
type: String, type: String,
required: true required: true
}, },
buttonLabel: {
type: String,
default: null
},
buttonClass: { buttonClass: {
type: String, type: String,
required: false, required: false,
@ -52,20 +59,25 @@ export default defineComponent({
default: null default: null
} }
}, },
computed: {
methods: { copyText() {
copy() {
let text = this.copyText(this.buttonText)
console.log(text)
navigator.clipboard.writeText(text)
},
copyText(defaultText) {
let selection = this.element?.getSelection()?.toString() let selection = this.element?.getSelection()?.toString()
if (selection) { if (selection) {
return selection return selection
} else return defaultText } else return this.buttonText
}, },
},
methods: {
copy() {
let text = this.copyText
console.log(text)
navigator.clipboard.writeText(text)
Notify.create({
message: `copied ${this.shorten(this.copyText, 30)}`,
})
},
} }
}) })
</script> </script>

View File

@ -2,7 +2,6 @@
<q-btn <q-btn
:class='buttonClass + (isFollowing ? "button-unfollow" : "button-follow")' :class='buttonClass + (isFollowing ? "button-unfollow" : "button-follow")'
:size='buttonSize' :size='buttonSize'
:disable="!$store.getters.canSignEventsAutomatically"
unelevated unelevated
:text-color='isFollowing ? "" : "secondary"' :text-color='isFollowing ? "" : "secondary"'
dense dense

View File

@ -0,0 +1,390 @@
<template>
<div style='border: 1px solid var(--q-accent); border-radius: .5rem;'>
<q-expansion-item
dense
dense-toggle
group='info'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>learn about Nostr</span>
</template>
<q-card-section>
<p>
the <a href='https://github.com/fiatjaf/nostr' target='_blank'>Nostr</a> protocol is
a decentralized and censorship resistant distributed information network that relies on clients and relays.
relays store user data. clients communicate with the relays to save and fetch said user data.
</p>
<ul>
<li>users choose which relays to store their data on, meaning no one centralized entity has the power
to remove your data from the network (so it is recommended to use multiple relays)</li>
<li>users choose which clients to use, meaning no one centralized website can stop you from accessing the network</li>
<li>any client can be used with any relay, meaning users can choose their relays and client independently</li>
</ul>
<p>
astral is a client for Nostr. while astral is implementing a social media usecase of Nostr, the possibilities of Nostr are endless.
<a href='https://jesterui.github.io/#/game/jester1y7du0yq7uzfzhxr2xgd64lmchfpf54evjsa59ff4f2mgh83h79rs9k7ffq'>Jester</a>
is a beta peer to peer chess client implemented over Nostr.
</p>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='info'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>do I need a key?</span>
</template>
<q-card-section>
<p>
if you would just like to look around you do not need a key pair, simply close this
dialog popup. however, if you want to post or save your profile and follows you will
need to create a key pair if you don't have one already.
</p>
<p>
if you decide to just look around and want to login at a later time hit the set user <q-icon name='login'/>
button in the user menu.
</p>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='info'
class="no-padding full-width"
header-class='items-center'
>
<!-- <div class='flex row justify-between'> -->
<template #header>
<span class='full-width text-bold'>what is a key pair?</span>
</template>
<q-card-section>
in order to participate in the Nostr network you will need to a public key and private key pair.
this key pair can be used in any Nostr client to login.
<q-list bordered padding class="q-mt-sm q-mb-sm">
<q-item>
<q-item-section>
<q-item-label>public key</q-item-label>
<q-item-label caption>
publicly known unique ID associated with your user on the Nostr
network. can be shared freely. others can see your posts or
follow you using only your public key.
</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label>private key</q-item-label>
<q-item-label caption>
<strong>KEEP THIS SECRET!</strong> secret key used to sign for
(or unlock) your public key. all content from your user public
key will need a signature derived from your private key before
being relayed. if a bad actor discovers your private key they
can impersonate you on Nostr network and see your encrypted dms.
</q-item-label>
</q-item-section>
</q-item>
</q-list>
<p>
your public key is created from your private key via a one way hash
function, meaning:
<ul>
<li>your public key can be calculated from your private
key - which is why you only need to enter your private key for astral to
know your public key</li>
<li>your private key cannot be calculated from your public key - which
is why you can freely share your public key without compromising your
private key</li>
</ul>
</p>
<p>
through the magic of cryptograpic functions, this private and public key pair
allows you to sign for Nostr events, which could represent a post, profile
settings, or follows list, in a cryptographically verifiable manner (similar to
how you sign for a bitcoin transaction)
</p>
<p>
you may see your keys displayed in a couple different key formats depending on
the Nostr client you use, please don't be alarmed. they both represent the same
byte data, they just use different encoding methods to be human readable. the
'npub' (for <span style='text-decoration: underline;'>N</span>ostr
<span style='text-decoration: underline;'>pub</span>lic key) and 'nsec'
(for <span style='text-decoration: underline;'>N</span>ostr
<span style='text-decoration: underline;'>sec</span>ret key) format that Damus
uses is preferable over the hex format that astral uses because there is a visual
indicator preventing the user from mixing up their public and private key. astral
will adopt this format in the future.
</p>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='info'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>why shouldn't I enter my private key?</span>
</template>
<q-card-section>
<p>
anytime you enter your private key into any Nostr client, you are trusting that client to:
<ol>
<li>not store your private key</li>
<li>not have any vulnerabilities that a bad actor can exploit to steal your private key</li>
</ol>
while I can promise you that astral does not store your private key (it is stored locally in your
browser and is NEVER sent to back to astral) and that I am doing my best to prevent vulnerabilities,
it is still recommeneded that you <strong>DO NOT TRUST ME</strong>.
</p>
<p>
fortunately, on desktop devices Nostr provides an easy way for you to sign into Nostr clients without
ever providing the client with your private key via browser extensions like
<a href='https://getalby.com/' target='_blank'>getAlby</a> or <a href='https://github.com/fiatjaf/nos2x#install' target='_blank'>nos2x</a>.
these browser extensions will store your private key locally
in your browser. when the client needs to send an event or decrypt your messages (ie. use your
private key), it will employ your browser extension to do the necessary cryptographic functions.
see <strong>how to get a key pair?</strong> section below for instructions on how to use these
browser extensions.
</p>
<p>
unfortunately, on mobile devices you cannot use browser extensions so it will be
necessary to enter your private key for now. this is a known and important issue
that is being worked on, and there should be some better solutions for private key
management on mobile devices coming soon.
</p>
</q-card-section>
</q-expansion-item>
<div v-if='$store.state.keys.priv' style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
v-if='$store.state.keys.priv'
dense
dense-toggle
group='info'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>how do I remove my private key if I already entered it?</span>
</template>
<q-card-section>
<p>
if you are on mobile you will not be able to use the directions below because browser extensions are
not supported on mobile devices. if you are on a desktop device please continue.
</p>
<ol>
<li>hit the <strong>VIEW YOUR KEYS</strong> at the bottom of the settings page and note down your private key</li>
<li>follow the instructions in <strong>how to get a key pair?</strong> section below, making sure to enter
your private key rather than generating a new one</li>
<li>hit the <strong>LOGOUT</strong> at the bottom of the settings page (<strong>LOGOUT</strong> will wipe your
user data but preserve the existing browser database, <strong>DELETE LOCAL DATA</strong> will wipe your user
data and the browser database</li>
<li>hit the <strong>USE PUBLIC KEY FROM EXTENSION</strong> option that should appear in the key input (if
astral doesn't refresh automatically please refresh the page</li>
<li>proceed to <strong>how to use astral?</strong> section below</li>
</ol>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='info'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>how to get a key pair?</span>
</template>
<q-card-section>
<p>
you can use any method you want to generate your keys, as long as they
conform to the <a href='https://github.com/nostr-protocol/nips/blob/master/01.md' target='_blank'>Nostr NIP-01</a>
specification.
<ul>
<li>if you are on a desktop device the recommeneded option will be using a browser extension like
<a href='https://getalby.com/' target='_blank'>getAlby</a> or <a href='https://github.com/fiatjaf/nos2x#install' target='_blank'>nos2x</a>.
(see <strong>why shouldn't I enter my private key?</strong> section above)</li>
<li>if you are on a mobile device the easiest option will be using astral</li>
<li>if you are a technical user who is concerned about key generation security you can try the local option</li>
</ul>
</p>
<p>
**if you already have a key pair and just want to enter it into a browser extension, follow the
instructions in the applicable sub-section below but enter your private key instead of hitting
'generate' (with nos2x make sure to hit save afterword). <strong>if your existing private key begins with
'nsec' you will need to convert it to hex format</strong> before saving it in the browser extensions, which
can be done <a href='https://damus.io/key/'>here</a>.**
</p>
<p>
choose your key generation method:
</p>
<div style='border: 1px solid var(--q-accent); border-radius: .5rem;'>
<q-expansion-item
dense
dense-toggle
group='generateKeys'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>use astral</span>
</template>
<q-card-section>
<ol>
<li>hit <strong>GENERATE KEYS</strong> button below in the key input</li>
<li>proceed to <strong>how to use astral?</strong> section below</li>
</ol>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='generateKeys'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<div class='full-width'>
<span class='text-bold'>use getAlby </span>
<span>longer setup than nos2x but is password protected, comes with a lightning wallet and is better maintained</span>
</div>
</template>
<q-card-section>
<ol>
<li>install <a href='https://getalby.com/' target='_blank'>getAlby</a> browser extension</li>
<li>complete alby setup (when it asks <strong>Do you have a lightning wallet?</strong> just hit <strong>Alby Wallet</strong>
if you do not have one)</li>
<li>open the getAlby extension options page (usually in dropdown when clicking on extension's icon at the top of your broswer)</li>
<li>hit the <strong>Settings</strong> tab</li>
<li>scroll down to the <strong>Nostr</strong> section and hit <strong>Generate</strong> (or if you already have a private key enter it)</li>
<li>refresh astral.ninja page and hit the <strong>USE PUBLIC KEY FROM EXTENSION</strong> option that should appear in the key input</li>
<li>proceed to <strong>how to use astral?</strong> section below</li>
</ol>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='generateKeys'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>use nos2x</span>
</template>
<q-card-section>
<ol>
<li>install <a href='https://github.com/fiatjaf/nos2x#install' target='_blank'>nos2x</a> browser extension</li>
<li>open the nos2x extension options page (usually in dropdown when clicking on extension's icon at the top of your broswer)</li>
<li>hit <strong>generate</strong> button (or if you already have a private key enter it and hit <strong>save</strong>)</li>
<li>refresh astral.ninja page and hit the <strong>USE PUBLIC KEY FROM EXTENSION</strong> option that should appear in the key input</li>
<li>proceed to <strong>how to use astral?</strong> section below</li>
</ol>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='generateKeys'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>use local machine</span>
</template>
<q-card-section>
<ol>
<li>open terminal</li>
<li>enter the command <pre>openssl rand -hex 32</pre></li>
<li>proceed to <strong>how to use astral?</strong> section below</li>
</ol>
</q-card-section>
</q-expansion-item>
</div>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='info'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>how to use astral?</span>
</template>
<q-card-section>
<ol>
<li>
<strong>enter your key</strong> if you don't have a key pair, or you have a key pair that you want to enter into
a Nostr browser extension, see <strong>how to get a key pair?</strong> section above.
</li>
<li>
<strong>choose bootstrap relays (optional)</strong> this section will only appear once a valid key has
been entered. if you are using a brand new key you don't need
to worry about this. if you are using an existing key and don't typically use any of the selected
relays, make sure to include a relay you typically use or astral may not be able to find your user.
the bootstrap relays are NOT used as your user's relay list, astral only uses these to find your
user's information and settings when loading your Nostr account.
</li>
<li>
<strong>hit PROCEED</strong> astral will login and attempt to find your user information. please
be patient as it can take a few minutes to completely sync with Nostr relays.
</li>
<li>
<strong>BACK UP YOUR KEYS!!!</strong> once you are taken to the settings page you should get a
popup displaying your Nostr keys. make sure to make a backup of your Nostr keys and keep it
somewhere safe. if you lose your Nostr keys you lose access to your Nostr user identity. there is no
astral customer service to reset your password.
</li>
<li>
<strong>edit settings</strong> hit the <strong>EDIT</strong> button for which ever section you would
like to edit.
<ul>
<li><strong>profile</strong> saving this section will broadcast your updated user profile to the
Nostr relays (if you have never set your relays on Nostr, astral will use astral's default relay
list to set and broadcast your set Nostr relays at the same time). all fields in the profile are
completely optional, you don't need to set a profile at all to use Nostr.
<ul>
<li><strong>name</strong> non-unique username that will be displayed accross Nostr</li>
<li><strong>about</strong> share a little about yourself</li>
<li><strong>picture</strong> image url for the picture you would like displayed as your Nostr
profile picture</li>
<li><strong>NIP-05 Identifier</strong> meant to be a unique, human readable identifier for
Nostr (read more <a href='https://github.com/nostr-protocol/nips/blob/master/05.md' target='_blank'>here</a>)
</li>
</ul>
</li>
<li><strong>preferences</strong> saving this section will update the look of astral for this browser.
this section is not synced to Nostr.</li>
<li><strong>relays</strong> saving this section will broadcast your updated relay list to the
Nostr relays.</li>
</ul>
</li>
</ol>
</q-card-section>
</q-expansion-item>
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'BaseInformation',
})
</script>

View File

@ -0,0 +1,119 @@
<template>
<div style='border: 3px double var(--q-accent); padding: .5rem; margin: .3rem;'>
<div class='flex row no-wrap justify-between full-width'>
<div style='font-size: .8rem;' :style='showQr ? "" : "width: 60%"' class='flex column'>
<div class='text-bold' style='font-size: 1.1rem;'>lightning invoice</div>
<div v-if='description'>desc: {{description}}</div>
<div>amount: {{amount ? `${amount} sats` : 'none specified'}}</div>
<div>created date: {{dateUTC(created)}}</div>
<div>created time: {{ timeUTC(created)}}</div>
<div v-if='expires'>expires date: {{dateUTC(expires)}}</div>
<div v-if='expires'>expires time: {{ timeUTC(expires)}}</div>
<div class='flex row no-wrap justify-start q-pt-xs' style='gap: .5rem;'>
<BaseButtonCopy :button-text='request' button-label='copy invoice' outline class='q-pr-xs' @click.stop='showQr=false'/>
<q-btn label='show qr' icon='qr_code_2' outline size='sm' dense unelevated class='q-pr-xs' @click.stop='renderQr'/>
</div>
</div>
<div class='flex column' style='font-size: .7rem; padding: .4rem;' :style='showQr ? "" : "width: 40%"'>
<div v-if='!showQr' class='break-word-wrap' style='overflow-y: auto; max-height: 170px;'>{{request}}</div>
<img v-show='showQr' ref='qr' style='object-fit: cover; min-height: 200px; min-width: 200px;'/>
</div>
</div>
</div>
</template>
<script>
import helpersMixin from '../utils/mixin'
import BaseButtonCopy from '../components/BaseButtonCopy'
import qrcodegen from 'nayuki-qr-code-generator'
// import {toSvgString} from 'awesome-qr-code-generator'
export default {
name: 'BaseInvoice',
mixins: [helpersMixin],
props: {invoice: {type: Object, required: true}},
components: {
BaseButtonCopy
},
data() {
return {
showQr: false,
// links: [],
}
},
computed: {
includesAmount() {
if (this.invoice.sections[2].name === 'separator') return false
return true
},
amount() {
if (this.includesAmount) return this.invoice.sections[2].value / 1000
return null
},
description() {
if (this.includesAmount) return this.invoice.sections[6].value
return this.invoice.sections[5].value
},
created() {
if (this.includesAmount) return parseInt(this.invoice.sections[4].value)
return parseInt(this.invoice.sections[3].value)
},
expires() {
let expiresValue = this.includesAmount ? parseInt(this.invoice.sections[8].value) : parseInt(this.invoice.sections[7].value)
let parsed = parseInt(expiresValue)
if (isNaN(parsed)) return null
return this.created + parsed
},
request() {
return this.invoice.paymentRequest
}
},
mounted() {
// console.log('invoice', this.invoice)
},
methods: {
renderQr(e) {
this.showQr = true
let qr = qrcodegen.QrCode.encodeText(this.request, qrcodegen.QrCode.Ecc.MEDIUM)
let svgSrc = this.toSvgString(qr, 4)
this.$refs.qr.src = svgSrc
console.log('qr', qr)
},
toSvgString(qr, border) {
let lightColor = '#FFFFFF'
let darkColor = '#000000'
if (border < 0)
throw new RangeError('Border must be non-negative')
let parts = []
for (let y = 0; y < qr.size; y++) {
for (let x = 0; x < qr.size; x++) {
if (qr.getModule(x, y))
parts.push(`M${x + border},${y + border}h1v1h-1z`)
}
}
let svg = `<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 ${qr.size + border * 2} ${qr.size + border * 2}" stroke="none">
<rect width="100%" height="100%" fill="${lightColor}"/>
<path d="${parts.join(' ')}" fill="${darkColor}"/>
</svg>
`
let blob = new Blob([svg], {type: 'image/svg+xml'})
let url = URL.createObjectURL(blob)
return url
}
},
}
</script>
<style lang='scss' scoped>
.q-btn {
opacity: .7;
transition: all .3s ease-in-out;
}
.q-btn:hover {
opacity: 1;
}
</style>

View File

@ -0,0 +1,358 @@
<template>
<div style='border: 1px solid var(--q-accent); border-radius: .5rem;'>
<q-expansion-item
dense
dense-toggle
group='info'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>learn about Nostr</span>
</template>
<q-card-section>
<p>
the <a href='https://github.com/fiatjaf/nostr' target='_blank'>Nostr</a> protocol is
a decentralized and censorship resistant distributed information network that relies on clients and relays.
relays store user data. clients communicate with the relays to save and fetch said user data.
</p>
<ul>
<li>users choose which relays to store their data on, meaning no one centralized entity has the power
to remove your data from the network (so it is recommended to use multiple relays)</li>
<li>users choose which clients to use, meaning no one centralized website can stop you from accessing the network</li>
<li>any client can be used with any relay, meaning users can choose their relays and client independently</li>
</ul>
<p>
astral is a client for Nostr. while astral is implementing a social media usecase of Nostr, the possibilities of Nostr are endless.
<a href='https://jesterui.github.io/#/game/jester1y7du0yq7uzfzhxr2xgd64lmchfpf54evjsa59ff4f2mgh83h79rs9k7ffq'>Jester</a>
is a beta peer to peer chess client implemented over Nostr.
</p>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='info'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>do I need a key?</span>
</template>
<q-card-section>
<p>
if you would just like to look around you do not need a key pair, simply close this
dialog popup. however, if you want to post or save your profile and follows you will
need to create a key pair if you don't have one already.
</p>
<p>
if you decide to just look around and want to login at a later time hit the set user <q-icon name='login'/>
button in the user menu.
</p>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='info'
class="no-padding full-width"
header-class='items-center'
>
<!-- <div class='flex row justify-between'> -->
<template #header>
<span class='full-width text-bold'>what is a key pair?</span>
</template>
<q-card-section>
in order to participate in the Nostr network you will need to a public key and private key pair.
this key pair can be used in any Nostr client to login.
<q-list bordered padding class="q-mt-sm q-mb-sm">
<q-item>
<q-item-section>
<q-item-label>public key</q-item-label>
<q-item-label caption>
publicly known unique ID associated with your user on the Nostr
network. can be shared freely. others can see your posts or
follow you using only your public key.
</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label>private key</q-item-label>
<q-item-label caption>
<strong>KEEP THIS SECRET!</strong> secret key used to sign for
(or unlock) your public key. all content from your user public
key will need a signature derived from your private key before
being relayed. if a bad actor discovers your private key they
can impersonate you on Nostr network and see your encrypted dms.
</q-item-label>
</q-item-section>
</q-item>
</q-list>
<p>
your public key is created from your private key via a one way hash
function, meaning:
<ul>
<li>your public key can be calculated from your private
key - which is why you only need to enter your private key for astral to
know your public key</li>
<li>your private key cannot be calculated from your public key - which
is why you can freely share your public key without compromising your
private key</li>
</ul>
</p>
<p>
through the magic of cryptograpic functions, this private and public key pair
allows you to sign for Nostr events, which could represent a post, profile
settings, or follows list, in a cryptographically verifiable manner (similar to
how you sign for a bitcoin transaction)
</p>
<p>
you may see your keys displayed in a couple different key formats depending on
the Nostr client you use, please don't be alarmed. they both represent the same
byte data, they just use different encoding methods to be human readable. the
'npub' (for <span style='text-decoration: underline;'>N</span>ostr
<span style='text-decoration: underline;'>pub</span>lic key) and 'nsec'
(for <span style='text-decoration: underline;'>N</span>ostr
<span style='text-decoration: underline;'>sec</span>ret key) format that Damus
uses is preferable over the hex format that astral uses because there is a visual
indicator preventing the user from mixing up their public and private key. astral
will adopt this format in the future.
</p>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='info'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>why shouldn't I enter my private key?</span>
</template>
<q-card-section>
<p>
anytime you enter your private key into any Nostr client, you are trusting that client to:
<ol>
<li>not store your private key</li>
<li>not have any vulnerabilities that a bad actor can exploit to steal your private key</li>
</ol>
while I can promise you that astral does not store your private key (it is stored locally in your
browser and is NEVER sent to back to astral) and that I am doing my best to prevent vulnerabilities,
it is still recommeneded that you <strong>DO NOT TRUST ME</strong>.
</p>
<p>
fortunately, on desktop devices Nostr provides an easy way for you to sign into Nostr clients without
ever providing the client with your private key via browser extensions like
<a href='https://getalby.com/' target='_blank'>getAlby</a> or <a href='https://github.com/fiatjaf/nos2x#install' target='_blank'>nos2x</a>.
these browser extensions will store your private key locally
in your browser. when the client needs to send an event or decrypt your messages (ie. use your
private key), it will employ your browser extension to do the necessary cryptographic functions.
see <strong>how to get a key pair?</strong> section below for instructions on how to use these
browser extensions.
</p>
<p>
unfortunately, on mobile devices you cannot use browser extensions so it will be
necessary to enter your private key for now. this is a known and important issue
that is being worked on, and there should be some better solutions for private key
management on mobile devices coming soon.
</p>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='info'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>how to get a key pair?</span>
</template>
<q-card-section>
<p>
you can use any method you want to generate your keys, as long as they
conform to the <a href='https://github.com/nostr-protocol/nips/blob/master/01.md' target='_blank'>Nostr NIP-01</a>
specification.
<ul>
<li>if you are on a desktop device the recommeneded option will be using a browser extension like
<a href='https://getalby.com/' target='_blank'>getAlby</a> or <a href='https://github.com/fiatjaf/nos2x#install' target='_blank'>nos2x</a>.
(see <strong>why shouldn't I enter my private key?</strong> section above)</li>
<li>if you are on a mobile device the easiest option will be using astral</li>
<li>if you are a technical user who is concerned about key generation security you can try the local option</li>
</ul>
</p>
<p>
if you already have a key pair and just want to enter it into a browser extension, follow the
instructions in the applicable sub-section below but enter your private key instead of hitting
'generate' (with nos2x make sure to hit save afterword)
</p>
<p>
choose your key generation method:
</p>
<div style='border: 1px solid var(--q-accent); border-radius: .5rem;'>
<q-expansion-item
dense
dense-toggle
group='generateKeys'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>use astral</span>
</template>
<q-card-section>
<ol>
<li>hit <strong>GENERATE KEYS</strong> button below in the key input</li>
<li>proceed to <strong>how to use astral?</strong> section below</li>
</ol>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='generateKeys'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>use nos2x</span>
</template>
<q-card-section>
<ol>
<li>install <a href='https://github.com/fiatjaf/nos2x#install' target='_blank'>nos2x</a> browser extension</li>
<li>open the nos2x extension options page (usually in dropdown when clicking on extension's icon at the top of your broswer)</li>
<li>hit <strong>generate</strong> button</li>
<li>refresh astral.ninja page and hit the <strong>USE PUBLIC KEY FROM EXTENSION</strong> option that should appear in the key input</li>
<li>proceed to <strong>how to use astral?</strong> section below</li>
</ol>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='generateKeys'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<div class='full-width'>
<span class='text-bold'>use getAlby </span>
<span>longer setup than nos2x but is password protected, comes with a lightning wallet and is better maintained</span>
</div>
</template>
<q-card-section>
<ol>
<li>install <a href='https://getalby.com/' target='_blank'>getAlby</a> browser extension</li>
<li>complete alby setup (when it asks <strong>Do you have a lightning wallet?</strong> just hit <strong>Alby Wallet</strong>
if you do not have one)</li>
<li>open the getAlby extension options page (usually in dropdown when clicking on extension's icon at the top of your broswer)</li>
<li>hit the <strong>Settings</strong> tab</li>
<li>scroll down to the <strong>Nostr</strong> section and hit <strong>Generate</strong></li>
<li>refresh astral.ninja page and hit the <strong>USE PUBLIC KEY FROM EXTENSION</strong> option that should appear in the key input</li>
<li>proceed to <strong>how to use astral?</strong> section below</li>
</ol>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='generateKeys'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>use local machine</span>
</template>
<q-card-section>
<ol>
<li>open terminal</li>
<li>enter the command <pre>openssl rand -hex 32</pre></li>
<li>proceed to <strong>how to use astral?</strong> section below</li>
</ol>
</q-card-section>
</q-expansion-item>
</div>
</q-card-section>
</q-expansion-item>
<div style='max-height: 0; border-top: 1px solid var(--q-accent)'/>
<q-expansion-item
dense
dense-toggle
group='info'
class="no-padding full-width"
header-class='items-center'
>
<template #header>
<span class='full-width text-bold'>how to use astral?</span>
</template>
<q-card-section>
<ol>
<li>
<strong>enter your key</strong> if you don't have a key pair, or you have a key pair that you want to enter into
a Nostr browser extension, see <strong>how to get a key pair?</strong> section above.
</li>
<li>
<strong>choose bootstrap relays (optional)</strong> this section will only appear once a valid key has
been entered. if you are using a brand new key you don't need
to worry about this. if you are using an existing key and don't typically use any of the selected
relays, make sure to include a relay you typically use or astral may not be able to find your user.
the bootstrap relays are NOT used as your user's relay list, astral only uses these to find your
user's information and settings when loading your Nostr account.
</li>
<li>
<strong>hit PROCEED</strong> astral will login and attempt to find your user information. please
be patient as it can take a few minutes to completely sync with Nostr relays.
</li>
<li>
<strong>BACK UP YOUR KEYS!!!</strong> once you are taken to the settings page you should get a
popup displaying your Nostr keys. make sure to make a backup of your Nostr keys and keep it
somewhere safe. if you lose your Nostr keys you lose access to your Nostr user identity. there is no
astral customer service to reset your password.
</li>
<li>
<strong>edit settings</strong> hit the <strong>EDIT</strong> button for which ever section you would
like to edit.
<ul>
<li><strong>profile</strong> saving this section will broadcast your updated user profile to the
Nostr relays (if you have never set your relays on Nostr, astral will use astral's default relay
list to set and broadcast your set Nostr relays at the same time). all fields in the profile are
completely optional, you don't need to set a profile at all to use Nostr.
<ul>
<li><strong>name</strong> non-unique username that will be displayed accross Nostr</li>
<li><strong>about</strong> share a little about yourself</li>
<li><strong>picture</strong> image url for the picture you would like displayed as your Nostr
profile picture</li>
<li><strong>NIP-05 Identifier</strong> meant to be a unique, human readable identifier for
Nostr (read more <a href='https://github.com/nostr-protocol/nips/blob/master/05.md' target='_blank'>here</a>)
</li>
</ul>
</li>
<li><strong>preferences</strong> saving this section will update the look of astral for this browser.
this section is not synced to Nostr.</li>
<li><strong>relays</strong> saving this section will broadcast your updated relay list to the
Nostr relays.</li>
</ul>
</li>
</ol>
</q-card-section>
</q-expansion-item>
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'BaseIssues',
})
</script>

View File

@ -14,6 +14,7 @@
label='show full post' label='show full post'
@click.stop="expand" @click.stop="expand"
/> />
<BaseInvoice v-if='invoice' :invoice='invoice'/>
<!-- <div v-if='links.length'> <!-- <div v-if='links.length'>
<BaseLinkPreview v-for='(link, idx) of links' :key='idx' :url='link' /> <BaseLinkPreview v-for='(link, idx) of links' :key='idx' :url='link' />
</div> --> </div> -->
@ -27,7 +28,8 @@ import deflist from 'markdown-it-deflist'
import taskLists from 'markdown-it-task-lists' import taskLists from 'markdown-it-task-lists'
import emoji from 'markdown-it-emoji' import emoji from 'markdown-it-emoji'
import helpersMixin from '../utils/mixin' import helpersMixin from '../utils/mixin'
// import BaseLinkPreview from 'components/BaseLinkPreview.vue' import * as bolt11Parser from 'light-bolt11-decoder'
import BaseInvoice from 'components/BaseInvoice.vue'
const md = MarkdownIt({ const md = MarkdownIt({
html: false, html: false,
@ -56,6 +58,7 @@ md.use(subscript)
trimmed.endsWith('.png') || trimmed.endsWith('.png') ||
trimmed.endsWith('.jpeg') || trimmed.endsWith('.jpeg') ||
trimmed.endsWith('.jpg') || trimmed.endsWith('.jpg') ||
trimmed.endsWith('.svg') ||
trimmed.endsWith('.mp4') || trimmed.endsWith('.mp4') ||
trimmed.endsWith('.webm') || trimmed.endsWith('.webm') ||
trimmed.endsWith('.ogg') trimmed.endsWith('.ogg')
@ -76,9 +79,10 @@ md.use(subscript)
trimmed.endsWith('.gif') || trimmed.endsWith('.gif') ||
trimmed.endsWith('.png') || trimmed.endsWith('.png') ||
trimmed.endsWith('.jpeg') || trimmed.endsWith('.jpeg') ||
trimmed.endsWith('.jpg') trimmed.endsWith('.jpg') ||
trimmed.endsWith('.svg')
) { ) {
return `<img src="${src}" crossorigin async style="max-width: 90%; max-height: 50vh;">` return `<img src="${src}" crossorigin async loading='lazy' style="max-width: 90%; max-height: 50vh;">`
} else if ( } else if (
trimmed.endsWith('.mp4') || trimmed.endsWith('.mp4') ||
trimmed.endsWith('.webm') || trimmed.endsWith('.webm') ||
@ -178,14 +182,15 @@ md.linkify
export default { export default {
name: 'BaseMarkdown', name: 'BaseMarkdown',
mixins: [helpersMixin], mixins: [helpersMixin],
emits: ['expand'], emits: ['expand', 'resized'],
// components: { components: {
// BaseLinkPreview, BaseInvoice,
// }, },
data() { data() {
return { return {
html: '', html: '',
invoice: null,
// links: [], // links: [],
} }
}, },
@ -201,6 +206,23 @@ export default {
}, },
}, },
computed: {
parsedContent() {
const bolt11Regex = /\b(?<i>(lnbc|LNBC)[0-9a-zA-Z]*1[0-9a-zA-Z]+)\b/g
const replacer = (match, index) => {
try {
this.invoice = bolt11Parser.decode(match)
return ''
} catch (e) {
console.log('invoice parsing error', e)
return match
}
}
let replacedContent = this.content.replace(bolt11Regex, replacer)
return replacedContent
}
},
mounted() { mounted() {
this.render() this.render()
}, },
@ -211,7 +233,7 @@ export default {
methods: { methods: {
render() { render() {
this.html = md.render(this.content) + this.$refs.append.innerHTML this.html = md.render(this.parsedContent) + this.$refs.append.innerHTML
// md.render(this.$refs.src.innerHTML) + this.$refs.append.innerHTML // md.render(this.$refs.src.innerHTML) + this.$refs.append.innerHTML
this.$refs.html.querySelectorAll('img').forEach(img => { this.$refs.html.querySelectorAll('img').forEach(img => {
img.addEventListener('click', (e) => { img.addEventListener('click', (e) => {
@ -221,6 +243,10 @@ export default {
} else if (document.exitFullscreen) { } else if (document.exitFullscreen) {
document.exitFullscreen() document.exitFullscreen()
} }
this.$emit('resized')
})
img.addEventListener('load', (e) => {
this.$emit('resized')
}) })
}) })
// if (this.links.length === 0) { // if (this.links.length === 0) {

View File

@ -85,9 +85,15 @@
</a> </a>
</q-item-label> </q-item-label>
</div> </div>
<BaseMarkdown v-if="event.kind === 1" :content='event.interpolated.text' :long-form='isLongForm' @expand='isLongForm = !isLongForm' /> <BaseMarkdown
<BaseRelayRecommend v-else-if="event.kind === 2" :url="event.content" /> v-if="event.kind === 1"
<BaseMarkdown v-else> {{ cleanEvent }} </BaseMarkdown> :content='event.interpolated.text'
:long-form='isLongForm'
@expand='isLongForm = !isLongForm'
@resized='calcConnectorValues(10)'
/>
<BaseRelayRecommend v-else-if="event.kind === 2" :url="sanitize(event.content)" />
<pre v-else> {{ cleanEvent }} </pre>
<div <div
v-if='!isEmbeded && (isQuote || isRepost)' v-if='!isEmbeded && (isQuote || isRepost)'
class='reposts flex column' class='reposts flex column'
@ -224,6 +230,7 @@ import BaseButtonInfo from 'components/BaseButtonInfo.vue'
import BaseButtonCopy from 'components/BaseButtonCopy.vue' import BaseButtonCopy from 'components/BaseButtonCopy.vue'
import BaseMarkdown from 'components/BaseMarkdown.vue' import BaseMarkdown from 'components/BaseMarkdown.vue'
import BaseRelayRecommend from 'components/BaseRelayRecommend.vue' import BaseRelayRecommend from 'components/BaseRelayRecommend.vue'
import * as DOMPurify from 'dompurify'
export default defineComponent({ export default defineComponent({
name: 'BasePost', name: 'BasePost',
@ -323,19 +330,18 @@ export default defineComponent({
}, },
cleanEvent() { cleanEvent() {
return JSON.stringify(cleanEvent(this.event), null, '\n\t') return this.sanitize(JSON.stringify(cleanEvent(this.event), null, 2))
}, },
}, },
mounted() { mounted() {
// console.log('mounted')
if (!this.isEmbeded && (this.isQuote || this.isRepost)) { if (!this.isEmbeded && (this.isQuote || this.isRepost)) {
if (!Array.isArray(this.reposts)) this.reposts = [] if (!Array.isArray(this.reposts)) this.reposts = []
this.processTaggedEvents(this.mentionEvents, this.reposts) this.processTaggedEvents(this.mentionEvents, this.reposts)
} }
this.calcConnectorValues() this.calcConnectorValues()
this.$emit('mounted') this.$emit('mounted')
this.isLongForm = this.event.interpolated.text.length > 500 this.isLongForm = this.event.content.length > 600
}, },
activated() { activated() {
@ -406,6 +412,10 @@ export default defineComponent({
console.log('post reply threads add-event', event) console.log('post reply threads add-event', event)
this.$emit('add-event', event) this.$emit('add-event', event)
}, },
sanitize(text) {
return DOMPurify.sanitize(text)
}
} }
}) })
</script> </script>

View File

@ -14,6 +14,7 @@
import helpersMixin from '../utils/mixin' import helpersMixin from '../utils/mixin'
import {cleanEvent} from '../utils/event' import {cleanEvent} from '../utils/event'
import BaseButtonCopy from 'components/BaseButtonCopy.vue' import BaseButtonCopy from 'components/BaseButtonCopy.vue'
import * as DOMPurify from 'dompurify'
export default { export default {
name: 'BaseRawEvent', name: 'BaseRawEvent',
@ -25,20 +26,16 @@ export default {
computed: { computed: {
cleaned() { cleaned() {
if (Array.isArray(this.event)) return this.event.map(cleanEvent) console.log('cleaned', JSON.parse(DOMPurify.sanitize(JSON.stringify(this.event))))
return cleanEvent(this.event) if (Array.isArray(this.event)) return this.event.map(event => cleanEvent(this.sanitize(event)))
return cleanEvent(this.sanitize(this.event))
} }
}, },
// methods: { methods: {
// copyText(defaultText) { sanitize(event) {
// console.log('defaultText: ', defaultText) return JSON.parse(DOMPurify.sanitize(JSON.stringify(this.event)))
// let selection = window.getSelection().toString() },
// console.log('selection: ', selection) }
// if (selection) {
// return selection
// } else return defaultText
// },
// }
} }
</script> </script>

View File

@ -1,7 +1,7 @@
<template> <template>
<div :class='(bordered ? "bordered-avatar" : "") + (hoverEffect ? " hovered-avatar" : "")'> <div :class='(bordered ? "bordered-avatar" : "") + (hoverEffect ? " hovered-avatar" : "")'>
<q-avatar :rounded='!round' class='relative-position' :size='size' @click.stop="toProfile(pubkey)"> <q-avatar :rounded='!round' class='relative-position' :size='size' @click.stop="toProfile(pubkey)">
<img :src="$store.getters.avatar(pubkey)" crossorigin async/> <img :src="$store.getters.avatar(pubkey)" loading='lazy' crossorigin async/>
<div :class='alignRight ? "icon-right" : "icon-left"' class='q-pt-xs'> <div :class='alignRight ? "icon-right" : "icon-left"' class='q-pt-xs'>
<BaseButtonNIP05 <BaseButtonNIP05
v-if='showVerified' v-if='showVerified'

View File

@ -3,88 +3,22 @@
<!-- <div v-if="showKeyInitialization"> --> <!-- <div v-if="showKeyInitialization"> -->
<q-card class='relative-position full-width'> <q-card class='relative-position full-width'>
<q-btn icon='close' size='md' flat round class='absolute-top-right z-top' @click='$emit("look-around")'/> <q-btn icon='close' size='md' flat round class='absolute-top-right z-top' @click='$emit("look-around")'/>
<h1 class="text-h6 q-pr-md">welcome to astral</h1>
<q-expansion-item <q-expansion-item
dense dense
dense-toggle expand-icon='help'
expand-icon='info'
expanded-icon='expand_less' expanded-icon='expand_less'
class="intro no-padding" class="intro no-padding full-width items-center"
header-class='items-center'
> >
<!-- <div class='flex row justify-between'> -->
<template #header> <template #header>
<h1 class="text-h6 q-pr-md">welcome to astral</h1> <span class='full-width'>click here to learn about Nostr, your keys, and how to use astral</span>
</template> </template>
<!-- </div> --> <BaseInformation/>
<p> <span style='padding: .2rem 0 0 .2rem;'>note: after login this same information can be found in
astral is a social media client for the <a href='https://github.com/fiatjaf/nostr' target='_blank'>Nostr</a> protocol, the <strong>faq</strong> section at the bottom of the settings page</span>
a decentralized and censorship resistant distributed information network that relies on clients and relays.
relays store user data. clients communicate with the relays to save and fetch said user data. users choose
which relays to store their data on, meaning no one centralized entity has the power to remove your data from the
network (so it is recommended to use multiple relays). users choose which clients to use, meaning no one centralized
website can stop you from accessing the network. any client can be used with any relay, meaming users can choose
their relays and client independently.
</p>
<p>
while astral is implementing a social media usecase of Nostr, the possibilities of Nostr are endless.
<a href='https://jesterui.github.io/#/game/jester1y7du0yq7uzfzhxr2xgd64lmchfpf54evjsa59ff4f2mgh83h79rs9k7ffq'>Jester</a>
is a beta peer to peer chess client implemented over Nostr.
</p>
</q-expansion-item>
<q-expansion-item
dense
dense-toggle
expand-icon='info'
expanded-icon='expand_less'
class="intro no-padding"
>
<!-- <div class='flex row justify-between'> -->
<template #header>
<h2 class="text-subtitle2 q-pr-md">enter your key</h2>
</template>
<q-card-section class="intro no-padding">
in order to participate in the Nostr network you will need to a public key and private key pair:
<q-list bordered padding class="q-mt-sm q-mb-sm">
<q-item>
<q-item-section>
<q-item-label>public key</q-item-label>
<q-item-label caption>
publicly known unique ID associated with your user on the Nostr
network. can be shared freely. others can see your posts or
follow you using only your public key.
</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label>private key</q-item-label>
<q-item-label caption>
<strong>KEEP THIS SECRET!</strong> secret key used to sign for
(or unlock) your public key. all content from your user public
key will need a signature derived from your private key before
being relayed. if a bad actor discovers your private key they
can impersonate you on Nostr network.
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-card-section class="onboard no-padding">
<p>
if you don't have a Nostr key pair you can either generate a new key
pair below or close this popup to just look around. if you would like
to login at a later time hit the login <q-icon name='login'/> button.
</p>
<!-- <q-btn-group spread unelevated class='q-gutter-xl'>
<q-btn size="sm" outline @click="generate" color="primary">
generate
</q-btn>
<q-btn size="sm" outline color="primary" @click='$emit("look-around")'>
look around
</q-btn>
</q-btn-group> -->
</q-card-section>
</q-expansion-item> </q-expansion-item>
<h2 class="text-subtitle2 q-pr-md">enter your key</h2>
<q-form @submit="proceed"> <q-form @submit="proceed">
<q-card-section class="key-entry no-padding"> <q-card-section class="key-entry no-padding">
<q-btn-group spread unelevated> <q-btn-group spread unelevated>
@ -163,9 +97,9 @@
</template> </template>
</q-input> </q-input>
</q-card-section> </q-card-section>
<div v-if='isBeck32Key(key)'> <!-- <div v-if='isBeck32Key(key)'>
{{ hexKey }} {{ hexKey }}
</div> </div> -->
</q-form> </q-form>
<q-expansion-item <q-expansion-item
v-if='isKeyValid' v-if='isKeyValid'
@ -241,6 +175,7 @@ import { validateWords } from 'nostr-tools/nip06'
import { generatePrivateKey } from 'nostr-tools' import { generatePrivateKey } from 'nostr-tools'
import { decode } from 'bech32-buffer' import { decode } from 'bech32-buffer'
import BaseSelectMultiple from 'components/BaseSelectMultiple.vue' import BaseSelectMultiple from 'components/BaseSelectMultiple.vue'
import BaseInformation from 'components/BaseInformation.vue'
export default defineComponent({ export default defineComponent({
name: 'TheKeyInitializationDialog', name: 'TheKeyInitializationDialog',
@ -249,6 +184,7 @@ export default defineComponent({
components: { components: {
BaseSelectMultiple, BaseSelectMultiple,
BaseInformation,
}, },
setup() { setup() {

View File

@ -40,11 +40,11 @@
class='menu-item' class='menu-item'
:dense='compactMode' :dense='compactMode'
:style='compactMode ? "" : "min-height: 2.75rem;"' :style='compactMode ? "" : "min-height: 2.75rem;"'
:active="$route.name === item.title" :active="($route.name === item.title || $route.path.split('/')[1] === item.title)"
active-class='' active-class=''
@click='(event) => handleClick(event, item)' @click='(event) => handleClick(event, item)'
:key='item.title' :key='item.title'
:class="($route.name === item.title ? 'menu-item-active text-accent ' : '') + :class="(($route.name === item.title || $route.path.split('/')[1] === item.title) ? 'menu-item-active text-accent ' : '') +
(compactMode ? 'no-margin no-padding col' : 'self-end q-px-none')" (compactMode ? 'no-margin no-padding col' : 'self-end q-px-none')"
> >
<q-item-section v-if='!compactMode' class='gt-sm text-uppercase' style='font-size: 1rem;'> <q-item-section v-if='!compactMode' class='gt-sm text-uppercase' style='font-size: 1rem;'>

View File

@ -85,6 +85,7 @@ export default {
replies: 'replies', replies: 'replies',
profile: 'profile', profile: 'profile',
relays: 'relays', relays: 'relays',
faq: 'faq',
users: 'users', users: 'users',
nip05Maintainer: 'NIP05 maintainer', nip05Maintainer: 'NIP05 maintainer',
inactiveRelays: 'inactive relays', inactiveRelays: 'inactive relays',

View File

@ -2,7 +2,7 @@
<q-layout> <q-layout>
<link v-if='!updatingFont' id='font-link' rel="stylesheet" :href="`https://fonts.googleapis.com/css2?family=${googleFontsName}`" crossorigin/> <link v-if='!updatingFont' id='font-link' rel="stylesheet" :href="`https://fonts.googleapis.com/css2?family=${googleFontsName}`" crossorigin/>
<q-dialog v-if='!$store.state.keys.pub' v-model='initializeKeys' persistent> <q-dialog v-if='!$store.state.keys.pub' v-model='initializeKeys' persistent>
<TheKeyInitializationDialog style='max-height: 85vh' @look-around='lookingAround=true'/> <TheKeyInitializationDialog style='max-height: 85vh' @look-around='setLookingAroundMode'/>
</q-dialog> </q-dialog>
<div id='layout-container' :ripple='false'> <div id='layout-container' :ripple='false'>
<div id='left-drawer' class='flex justify-end'> <div id='left-drawer' class='flex justify-end'>
@ -20,8 +20,8 @@
<q-page-container ref='pageContainer'> <q-page-container ref='pageContainer'>
<!-- <TheKeyInitializationDialog v-if='!$store.state.keys.pub && !lookingAround' @look-around='lookingAround=true'/> --> <!-- <TheKeyInitializationDialog v-if='!$store.state.keys.pub && !lookingAround' @look-around='lookingAround=true'/> -->
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<keep-alive > <keep-alive :include='["Feed", "Messages", "Notifications"]'>
<component :is="Component" :key='$route.path' @scroll-to-rect='scrollToRect' @reply-event='setReplyEvent' @update-font='updateFont'/> <component :is="Component" :key='$route.path' :looking-around='lookingAround' @scroll-to-rect='scrollToRect' @reply-event='setReplyEvent' @update-font='updateFont'/>
</keep-alive> </keep-alive>
</router-view> </router-view>
</q-page-container> </q-page-container>
@ -161,12 +161,14 @@ export default defineComponent({
setup () { setup () {
const $q = useQuasar() const $q = useQuasar()
// const cachedPages = ref(['feed', 'notifications', 'messages'])
return $q return $q
}, },
data() { data() {
return { return {
cachedPages: ['Feed', 'Notifications', 'Messages'],
middlePagePos: {}, middlePagePos: {},
fabPos: [0, 10], fabPos: [0, 10],
draggingFab: false, draggingFab: false,
@ -243,7 +245,7 @@ export default defineComponent({
}, },
preserveScrollPos(to, from) { preserveScrollPos(to, from) {
this.middlePagePos[from.fullPath] = getVerticalScrollPosition(this.scrollingContainer) if (this.cachedPages.map(page => page.toLowerCase()).includes(from.name)) this.middlePagePos[from.fullPath] = getVerticalScrollPosition(this.scrollingContainer)
}, },
restoreScrollPos(to, from) { restoreScrollPos(to, from) {
@ -283,11 +285,8 @@ export default defineComponent({
if (this.hasLaunched) { if (this.hasLaunched) {
activateSub() activateSub()
} }
if (this.$store.state.keys.pub) { if (this.$store.state.keys.pub) this.$store.dispatch('launch')
this.$store.dispatch('launch') else this.$store.dispatch('launchWithoutKey')
} else {
this.$store.dispatch('launchWithoutKey')
}
this.hasLaunched = true this.hasLaunched = true
}, },
@ -409,6 +408,9 @@ export default defineComponent({
// } // }
} }
// console.log('font', getCssVar('font'), this.googleFontsName) // console.log('font', getCssVar('font'), this.googleFontsName)
},
setLookingAroundMode() {
this.lookingAround = true
} }
}, },
}) })

View File

@ -2,7 +2,7 @@
<q-page ref='page'> <q-page ref='page'>
<BaseHeader>{{ $t('thread') }}</BaseHeader> <BaseHeader>{{ $t('thread') }}</BaseHeader>
<div v-if="ancestorsCompiled.length || rootAncestor"> <div ref='ancestors' v-if="ancestorsCompiled.length || rootAncestor">
<BasePostThread :events="ancestorsCompiled" is-ancestors @add-event='addEventAncestors'/> <BasePostThread :events="ancestorsCompiled" is-ancestors @add-event='addEventAncestors'/>
</div> </div>
@ -17,7 +17,7 @@
<div v-else> <div v-else>
{{ $t('event') }} {{ $route.params.eventId }} {{ $t('event') }} {{ $route.params.eventId }}
</div> </div>
<BaseRelayList v-if="event?.seen_on?.length" :event='event' class='q-px-sm'/> <BaseRelayList v-if="event?.seen_on?.length" :event='event' class='q-px-sm'/>
</q-item> </q-item>
<q-separator color='accent' size='1px'/> <q-separator color='accent' size='1px'/>
@ -28,7 +28,7 @@
<BasePostThread :events="thread" @add-event='processChildEvent'/> <BasePostThread :events="thread" @add-event='processChildEvent'/>
</div> </div>
</div> </div>
<div style='min-height: 70vh;'/> <div style='min-height: 30vh;'/>
</q-page> </q-page>
</template> </template>
@ -86,11 +86,11 @@ export default defineComponent({
} }
}, },
activated() { mounted() {
this.start() this.start()
}, },
deactivated() { beforeUnmount() {
this.stop() this.stop()
}, },

View File

@ -29,10 +29,10 @@
:label='"load " + unreadFeed[tab].length + " unread"' :label='"load " + unreadFeed[tab].length + " unread"'
@click='loadUnread' @click='loadUnread'
/> />
<BasePostThread v-for='(item, index) in feed[tab]' :key='index' :events="item" class='full-width' @add-event='processEvent'/> <BasePostThread v-for='(item, index) in items' :key='index' :events="item" class='full-width' @add-event='processEvent'/>
<BaseButtonLoadMore <BaseButtonLoadMore
:loading-more='loadingMore' :loading-more='loadingMore'
label='load another day' :label='items.length === feed[tab].length ? "load another day" : "load 100 more"'
@click='loadMore' @click='loadMore'
/> />
</q-page> </q-page>
@ -43,7 +43,7 @@ import { defineComponent } from 'vue'
import helpersMixin from '../utils/mixin' import helpersMixin from '../utils/mixin'
import {addToThread} from '../utils/threads' import {addToThread} from '../utils/threads'
import {isValidEvent} from '../utils/event' import {isValidEvent} from '../utils/event'
import {streamFeed, dbFeed, dbUserFollows} from '../query' import {dbFeed, dbUserFollows} from '../query'
import BaseButtonLoadMore from 'components/BaseButtonLoadMore.vue' import BaseButtonLoadMore from 'components/BaseButtonLoadMore.vue'
import { createMetaMixin } from 'quasar' import { createMetaMixin } from 'quasar'
@ -78,9 +78,23 @@ export default defineComponent({
BaseButtonLoadMore, BaseButtonLoadMore,
}, },
watch: {
lookingAround(curr, prev) {
if (curr) {
this.loadMore()
}
}
},
props: {
lookingAround: {
type: Boolean,
default: false,
}
},
data() { data() {
return { return {
listener: null,
reachedEnd: false, reachedEnd: false,
feed: { feed: {
follows: [], follows: [],
@ -88,6 +102,18 @@ export default defineComponent({
AI: [], AI: [],
bots: [] bots: []
}, },
feedCounts: {
follows: 100,
global: 100,
AI: 100,
bots: 100
},
unreadCounts: {
follows: 100,
global: 100,
AI: 100,
bots: 100
},
unreadFeed: { unreadFeed: {
follows: [], follows: [],
global: [], global: [],
@ -101,20 +127,21 @@ export default defineComponent({
loadingMore: true, loadingMore: true,
loadingUnread: false, loadingUnread: false,
tab: 'follows', tab: 'follows',
sub: null, since: Math.round(Date.now() / 1000),
since: Math.round(Date.now() / 1000) - (1 * 24 * 60 * 60),
profilesUsed: new Set(), profilesUsed: new Set(),
// index: 0, // index: 0,
active: false, lastLoaded: Math.round(Date.now() / 1000),
refreshInterval: null,
unsubscribe: null,
} }
}, },
computed: { computed: {
items() { items() {
if (this.tab === 'follows') return this.feed.follows if (this.tab === 'follows') return this.feed.follows.slice(0, this.feedCounts['follows'])
if (this.tab === 'global') return this.feed.global if (this.tab === 'global') return this.feed.global.slice(0, this.feedCounts['global'])
if (this.tab === 'AI') return this.feed.AI if (this.tab === 'AI') return this.feed.AI.slice(0, this.feedCounts['AI'])
if (this.tab === 'bots') return this.feed.bots if (this.tab === 'bots') return this.feed.bots.slice(0, this.feedCounts['bots'])
return [] return []
} }
}, },
@ -123,76 +150,68 @@ export default defineComponent({
this.bots = await this.getFollows(this.botTracker) this.bots = await this.getFollows(this.botTracker)
this.follows = await this.getFollows(this.$store.state.keys.pub) this.follows = await this.getFollows(this.$store.state.keys.pub)
this.loadMore() if (this.$store.state.keys.pub) this.loadMore()
else {
this.unsubscribe = this.$store.subscribe((mutation, state) => {
switch (mutation.type) {
case 'setKeys': {
this.loadingMore = true
setTimeout(this.loadMore(), 6)
break
}
}
})
}
if (this.follows.length === 0) { if (this.follows.length === 0) {
this.tab = 'global' this.tab = 'global'
} }
}, },
activated() {
// console.log('feed activated', this.index)
// this.$refs.virtualScroll.refresh(this.index)
this.active = true
},
async beforeUnmount() { async beforeUnmount() {
if (this.listener) this.listener.cancel() if (this.listener) this.listener.cancel()
if (this.sub) this.sub.cancel()
this.sub = null
this.profilesUsed.forEach(pubkey => this.$store.dispatch('cancelUseProfile', {pubkey})) this.profilesUsed.forEach(pubkey => this.$store.dispatch('cancelUseProfile', {pubkey}))
}, if (this.unsubscribe) this.unsubscribe()
deactivated() {
// console.log('feed deactivated', this.index)
this.active = false
}, },
methods: { methods: {
async loadMore() { async loadMore() {
this.loadingMore = true this.loadingMore = true
if (this.items.length < this.feed[this.tab].length) {
this.feedCounts[this.tab] += 100
this.loadingMore = false
return
}
let loadedFeed = {} let loadedFeed = {}
for (let feed of Object.keys(this.feed)) { for (let feed of Object.keys(this.feed)) {
loadedFeed[feed] = [] loadedFeed[feed] = []
} }
// let timer = setTimeout(() => { this.loadingMore = false }, 1000) this.since = this.since - (6 * 60 * 60)
if (this.sub) {
this.since = this.since - (24 * 60 * 60)
this.sub.update(this.since - (24 * 60 * 60))
} else this.sub = await streamFeed(this.since - (24 * 60 * 60), (event) => {
this.processEvent(event, this.unreadFeed)
})
let results = await dbFeed(this.since) let results = await dbFeed(this.since)
if (results) for (let event of results) this.processEvent(event, loadedFeed) if (results) for (let event of results) this.processEvent(event, loadedFeed)
for (let feed of Object.keys(this.feed)) { for (let feed of Object.keys(this.feed)) {
this.feed[feed] = this.feed[feed].concat(loadedFeed[feed]) this.feed[feed] = this.feed[feed].concat(loadedFeed[feed])
} }
this.refreshInterval = setInterval(async () => {
let results = await dbFeed(this.lastLoaded)
if (results) for (let event of results) this.processEvent(event, this.unreadFeed)
for (let feed of Object.keys(this.feed)) {
this.feed[feed] = this.feed[feed].concat(this.unreadFeed[feed])
}
}, 10000)
this.loadingMore = false this.loadingMore = false
// this.sub = await dbStreamFeed(this.since, event => {
// if (!timer) {
// this.processEvent(event, this.feed)
// return
// }
// clearTimeout(timer)
// timer = setTimeout(() => {
// for (let feed of Object.keys(this.feed)) {
// this.feed[feed] = this.feed[feed].concat(loadedFeed[feed])
// }
// timer = null
// this.loadingMore = false
// }, 300)
// this.loadingMore = false
// this.processEvent(event, loadedFeed)
// })
}, },
loadUnread() { loadUnread() {
this.loadingUnread = true this.loadingUnread = true
this.feed[this.tab] = this.unreadFeed[this.tab].concat(this.feed[this.tab]) this.feed[this.tab] = this.unreadFeed[this.tab].concat(this.feed[this.tab])
this.unreadFeed[this.tab] = [] this.unreadFeed[this.tab] = []
this.lastLoaded = Math.round(Date.now() / 1000)
this.loadingUnread = false this.loadingUnread = false
}, },

View File

@ -46,11 +46,11 @@ export default defineComponent({
} }
}, },
activated() { mounted() {
this.start() this.start()
}, },
deactivated() { beforeUnmount() {
this.stop() this.stop()
}, },
@ -64,9 +64,9 @@ export default defineComponent({
this.processEvent(event) this.processEvent(event)
}) })
this.sub.hashtagOld = await dbStreamTagKind('hashtag', this.$route.params.hashtagId.toLowerCase(), 1, event => { // this.sub.hashtagOld = await dbStreamTagKind('hashtag', this.$route.params.hashtagId.toLowerCase(), 1, event => {
this.processEvent(event) // this.processEvent(event)
}) // })
}, },
stop() { stop() {

View File

@ -43,7 +43,7 @@
<script> <script>
import {dbChats} from '../query' import {dbChats} from '../query'
import {streamMessages} from '../query' import {listenMessages} from '../query'
import helpersMixin from '../utils/mixin' import helpersMixin from '../utils/mixin'
import { createMetaMixin } from 'quasar' import { createMetaMixin } from 'quasar'
@ -79,20 +79,21 @@ export default {
} }
}, },
async activated() { async mounted() {
console.log('route', this.$route)
this.chats = await dbChats(this.$store.state.keys.pub) this.chats = await dbChats(this.$store.state.keys.pub)
if (this.chats.length === 0) this.noChats = true if (this.chats.length === 0) this.noChats = true
this.chats.forEach(({peer}) => this.useProfile(peer)) this.chats.forEach(({peer}) => this.useProfile(peer))
if (this.allChatsNeverRead) this.chats.forEach(({peer}) => this.$store.commit('haveReadMessage', peer)) if (this.allChatsNeverRead) this.chats.forEach(({peer}) => this.$store.commit('haveReadMessage', peer))
this.loading = false this.loading = false
this.sub = await streamMessages(async event => { this.sub = await listenMessages(async event => {
if (event.pubkey === this.$store.state.keys.pub) return if (event.pubkey === this.$store.state.keys.pub) return
this.chats = await dbChats(this.$store.state.keys.pub) this.chats = await dbChats(this.$store.state.keys.pub)
this.useProfile(event.pubkey) this.useProfile(event.pubkey)
}) })
}, },
deactivated() { beforeUnmount() {
if (this.sub) { if (this.sub) {
this.sub.cancel() this.sub.cancel()
this.sub = null this.sub = null

View File

@ -87,7 +87,7 @@
<script> <script>
import helpersMixin from '../utils/mixin' import helpersMixin from '../utils/mixin'
import {dbMessages, streamMessages} from '../query' import {dbMessages, listenMessages} from '../query'
import BaseMessage from 'components/BaseMessage.vue' import BaseMessage from 'components/BaseMessage.vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { createMetaMixin } from 'quasar' import { createMetaMixin } from 'quasar'
@ -179,14 +179,14 @@ export default {
let newMessages = await dbMessages( let newMessages = await dbMessages(
this.$store.state.keys.pub, this.$store.state.keys.pub,
this.$route.params.pubkey, this.$route.params.pubkey,
this.$store.state.unreadMessages[this.$route.params.pubkey] 50
) )
let newMessagesFiltered = await this.processMessages(newMessages) let newMessagesFiltered = await this.processMessages(newMessages)
this.messages.push(...newMessagesFiltered) this.messages.push(...newMessagesFiltered)
} }
this.$store.commit('haveReadMessage', this.$route.params.pubkey) this.$store.commit('haveReadMessage', this.$route.params.pubkey)
// this.$store.dispatch('useProfile', {pubkey: this.$route.params.pubkey, request: true}) // this.$store.dispatch('useProfile', {pubkey: this.$route.params.pubkey, request: true})
this.sub = await streamMessages(async event => { this.sub = await listenMessages(async event => {
let eventUserTags = event.tags let eventUserTags = event.tags
.filter(([t, v]) => t === 'p' && v) .filter(([t, v]) => t === 'p' && v)
.map(([_, v]) => v) .map(([_, v]) => v)

View File

@ -23,7 +23,7 @@
<script> <script>
import helpersMixin from '../utils/mixin' import helpersMixin from '../utils/mixin'
import {dbMentions, streamMentions} from '../query' import {dbMentions, listenMentions} from '../query'
import { createMetaMixin } from 'quasar' import { createMetaMixin } from 'quasar'
const metaData = { const metaData = {
@ -56,7 +56,7 @@ export default {
async activated() { async activated() {
if (this.$store.state.unreadNotifications) this.loadNew() if (this.$store.state.unreadNotifications) this.loadNew()
this.sub = streamMentions(this.$store.state.keys.pub, async event => { this.sub = listenMentions(this.$store.state.keys.pub, async event => {
let loadedNotificationsFiltered = await this.processNotifications([event]) let loadedNotificationsFiltered = await this.processNotifications([event])
if (loadedNotificationsFiltered.length === 0) return if (loadedNotificationsFiltered.length === 0) return
this.notifications = loadedNotificationsFiltered.concat(this.notifications) this.notifications = loadedNotificationsFiltered.concat(this.notifications)

View File

@ -171,11 +171,11 @@ export default defineComponent({
} }
}, },
activated() { mounted() {
this.start() this.start()
}, },
deactivated() { beforeUnmount() {
this.stop() this.stop()
}, },

View File

@ -189,6 +189,21 @@
</q-form> </q-form>
</div> </div>
<q-separator color='accent'/>
<q-expansion-item
dense
expand-icon='help'
expanded-icon='expand_less'
class="full-width items-center"
header-class='items-center'
>
<template #header>
<div class="text-bold flex justify-between no-wrap full-width" style='font-size: 1.1rem;'>{{ $t('faq') }}</div>
</template>
<q-card-section>
<BaseInformation/>
</q-card-section>
</q-expansion-item>
<q-separator color='accent'/> <q-separator color='accent'/>
<div class="flex no-wrap section" style='gap: .2rem;'> <div class="flex no-wrap section" style='gap: .2rem;'>
@ -204,13 +219,14 @@
<div class="text-lg text-bold tracking-wide leading-relaxed py-2"> <div class="text-lg text-bold tracking-wide leading-relaxed py-2">
Your keys <q-icon name="vpn_key" /> Your keys <q-icon name="vpn_key" />
</div> </div>
<p v-if="$store.state.keys.priv"> <p v-if="$store.state.keys.priv">Make sure you back up your private key!</p>
Make sure you back up your private key!
</p>
<p v-else>Your private key is not here!</p> <p v-else>Your private key is not here!</p>
<div class="mt-1 text-xs"> <div class="mt-1 text-xs">
Posts are published using your private key. Others can see your Posts are published using your private key. Others can see your
posts or follow you using only your public key. posts or follow you using only your public key.
**if you entered a key that starts with 'npub' or 'nsec' these keys will look different.
it is the same key you entered, just converted to a different display format (hex)**
</div> </div>
</q-card-section> </q-card-section>
@ -245,6 +261,7 @@ import {dbErase} from '../query'
import { getCssVar, setCssVar } from 'quasar' import { getCssVar, setCssVar } from 'quasar'
import BaseSelect from 'components/BaseSelect.vue' import BaseSelect from 'components/BaseSelect.vue'
import BaseSelectMultiple from 'components/BaseSelectMultiple.vue' import BaseSelectMultiple from 'components/BaseSelectMultiple.vue'
import BaseInformation from 'components/BaseInformation.vue'
import { createMetaMixin } from 'quasar' import { createMetaMixin } from 'quasar'
const metaData = { const metaData = {
@ -265,7 +282,8 @@ export default {
emits: ['update-font'], emits: ['update-font'],
components: { components: {
BaseSelect, BaseSelect,
BaseSelectMultiple BaseSelectMultiple,
BaseInformation,
}, },
data() { data() {
@ -376,12 +394,10 @@ export default {
mounted() { mounted() {
if (!this.$store.state.keys.pub) this.$router.push('/') if (!this.$store.state.keys.pub) this.$router.push('/')
console.log('initUser', this.$route.params.initUser)
if (this.$store.state.keys.pub && this.$route.params.initUser) { if (this.$store.state.keys.pub && this.$route.params.initUser) {
nextTick(() => { nextTick(() => {
setTimeout(() => { setTimeout(() => {
this.keysDialog = true this.keysDialog = true
console.log('initUser', this.$route.params.initUser)
}, 1000) }, 1000)
}) })
} }
@ -449,6 +465,7 @@ export default {
if (!Object.keys(this.$store.state.relays).length) this.saveRelays() if (!Object.keys(this.$store.state.relays).length) this.saveRelays()
this.$store.dispatch('setMetadata', this.metadata) this.$store.dispatch('setMetadata', this.metadata)
this.editingMetadata = false
}, },
clonePreferences() { clonePreferences() {
this.preferences = {} this.preferences = {}
@ -492,6 +509,7 @@ export default {
return return
} }
if (this.$store.getters.canSignEventsAutomatically) this.$store.commit('saveRelays', this.relays) if (this.$store.getters.canSignEventsAutomatically) this.$store.commit('saveRelays', this.relays)
this.editingRelays = false
}, },
savePreferences() { savePreferences() {
// this.loadFont(this.preferences.font) // this.loadFont(this.preferences.font)

View File

@ -130,6 +130,13 @@ export function streamFeed(
return stream('streamFeed', [since], callback) return stream('streamFeed', [since], callback)
} }
export function listenFeed(
since = Math.round(Date.now() / 1000),
callback = () => { }
) {
return stream('listenFeed', [since], callback)
}
export async function dbChats(pubkey) { export async function dbChats(pubkey) {
return call('dbChats', [pubkey]) return call('dbChats', [pubkey])
} }
@ -142,8 +149,8 @@ export async function streamUserMessages(pubkey, callback = () => { }) {
return stream('streamUserMessages', [pubkey], callback) return stream('streamUserMessages', [pubkey], callback)
} }
export async function streamMessages(callback = () => { }) { export async function listenMessages(callback = () => { }) {
return stream('streamMessages', [], callback) return stream('listenMessages', [], callback)
} }
export async function dbEvent(id) { export async function dbEvent(id) {
@ -170,8 +177,8 @@ export async function dbMentions(pubkey, limit = 50, until = Math.round(Date.now
return call('dbMentions', [pubkey, limit, until]) return call('dbMentions', [pubkey, limit, until])
} }
export function streamMentions(pubkey, callback = () => { }) { export function listenMentions(pubkey, callback = () => { }) {
return stream('streamMentions', [pubkey], callback) return stream('listenMentions', [pubkey], callback)
} }
export async function dbUnreadMentionsCount(pubkey, since = Math.round(Date.now() / 1000)) { export async function dbUnreadMentionsCount(pubkey, since = Math.round(Date.now() / 1000)) {

View File

@ -6,7 +6,7 @@ import initSqlJs from '@jlongster/sql.js'
import { SQLiteFS } from 'absurd-sql' import { SQLiteFS } from 'absurd-sql'
import IndexedDBBackend from 'absurd-sql/dist/indexeddb-backend' import IndexedDBBackend from 'absurd-sql/dist/indexeddb-backend'
import sqlWasm from '@jlongster/sql.js/dist/sql-wasm.wasm' import sqlWasm from '@jlongster/sql.js/dist/sql-wasm.wasm'
import mergebounce from 'mergebounce'
export var channel = new MessageChannel() export var channel = new MessageChannel()
relay.setPort(channel) relay.setPort(channel)
@ -185,6 +185,12 @@ async function initDb() {
// return true // return true
// } // }
let debouncedHandleInsertedEvent = mergebounce(
events => { for (let event of events) handleInsertedEvent(event) },
500,
{ 'concatArrays': true, 'promise': true, maxWait: 1500 }
)
function handleInsertedEvent(event) { function handleInsertedEvent(event) {
event = JSON.parse(event) event = JSON.parse(event)
for (let id in streams) { for (let id in streams) {
@ -214,7 +220,7 @@ function handleUpdatedEvent(event) {
function createTables(db, output = console.log) { function createTables(db, output = console.log) {
console.log('creating tables and indexes', db) console.log('creating tables and indexes', db)
db.create_function('handleInsertedEvent', event => { db.create_function('handleInsertedEvent', event => {
handleInsertedEvent(event) debouncedHandleInsertedEvent([event])
}) })
db.create_function('handleUpdatedEvent', event => { db.create_function('handleUpdatedEvent', event => {
handleUpdatedEvent(event) handleUpdatedEvent(event)
@ -330,6 +336,7 @@ function saveEventsToDb(events, output = console.log, outputTiming = console.log
if (!db) return if (!db) return
if (!active) return if (!active) return
if (saving) return if (saving) return
if (!events.length) return
saving = true saving = true
let start = Date.now() let start = Date.now()
console.debug(`saving ${events.length} events...`) console.debug(`saving ${events.length} events...`)
@ -462,6 +469,19 @@ const methods = {
} }
}, },
listenFeed(since, callback) {
// don't need to open relay sub bc launch already subs the mentions
// also don't need date restriction as db should always be up to date
// and only new messages will be inserted
return {
filter: {
kinds: [1, 2],
since
},
callback,
}
},
dbChats(pubkey) { dbChats(pubkey) {
let result = queryDb(` let result = queryDb(`
SELECT peer, MAX(last_message) last_message SELECT peer, MAX(last_message) last_message
@ -541,7 +561,7 @@ const methods = {
// } // }
// }, // },
streamMessages(callback) { listenMessages(callback) {
// don't need to open relay sub bc launch already subs the mentions // don't need to open relay sub bc launch already subs the mentions
// also don't need date restriction as db should always be up to date // also don't need date restriction as db should always be up to date
// and only new messages will be inserted // and only new messages will be inserted
@ -603,7 +623,7 @@ const methods = {
return result.map(row => JSON.parse(row.event)) return result.map(row => JSON.parse(row.event))
}, },
streamMentions(pubkey, callback) { listenMentions(pubkey, callback) {
// don't need to open relay sub bc launch already subs the mentions // don't need to open relay sub bc launch already subs the mentions
// also don't need date restriction as db should always be up to date // also don't need date restriction as db should always be up to date
// and only new messages will be inserted // and only new messages will be inserted
@ -853,7 +873,7 @@ const methods = {
}, },
prune(user, pubkeys) { prune(user, pubkeys) {
let until = Math.round(Date.now() / 1000) - (10 * 24 * 60 * 60) let until = Math.round(Date.now() / 1000) - (1 * 24 * 60 * 60)
let pubkeyList = `("${pubkeys.join('","')}")` let pubkeyList = `("${pubkeys.join('","')}")`
let result = queryDb(` let result = queryDb(`
DELETE DELETE

View File

@ -1,9 +1,18 @@
import mergebounce from 'mergebounce' import mergebounce from 'mergebounce'
import { relayPool } from 'nostr-tools' import { relayPool } from 'nostr-tools'
// import {debounce} from 'quasar'
export const pool = relayPool() export const pool = relayPool()
let mainUserSub = null // let mainUserSub = null
let adhocSub = null // let adhocSub = null
let poolSubs = {}
let relays = {}
let subs = {}
let active = true
let lastSync = 0
let dbWorkerPort = null
pool.onNotice((notice, relay) => { pool.onNotice((notice, relay) => {
try { try {
@ -13,14 +22,15 @@ pool.onNotice((notice, relay) => {
} }
}) })
let relays = {}
let subs = {}
let active = true
let lastSync = 0
let dbWorkerPort = null
let debounceCount = 0 let debounceCount = 0
// let debounceTime = Date.now()
// let debouncedEvents = []
// function emitEvents(events) {
// dbWorkerPort.postMessage({ type: 'events', events })
// debouncedEvents = []
// }
// const debouncedEmitEvents = debounce(emitEvents, 1000)
let debouncedEmitEvent = mergebounce( let debouncedEmitEvent = mergebounce(
events => dbWorkerPort.postMessage({ type: 'events', events }), events => dbWorkerPort.postMessage({ type: 'events', events }),
1000, 1000,
@ -29,6 +39,9 @@ let debouncedEmitEvent = mergebounce(
function onEvent(event, relay) { function onEvent(event, relay) {
if (!active) return if (!active) return
// debouncedEvents.push({ event, relay })
// if (debounceCount >= 2000 || Date.now() - debounceTime > 2000) emitEvents(debouncedEvents)
// debouncedEmitEvents(debouncedEvents)
if (debounceCount >= 2000) { if (debounceCount >= 2000) {
debouncedEmitEvent.flush() debouncedEmitEvent.flush()
debounceCount = 0 debounceCount = 0
@ -43,7 +56,7 @@ function onEose(url) {
function calcFilter(subName) { function calcFilter(subName) {
let compiledSubs = Object.values(subs) let compiledSubs = Object.values(subs)
.filter(sub => subName === sub.subName) .filter(sub => subName === (sub.subName || 'adhoc'))
// .map(([_, sub]) => sub) // .map(([_, sub]) => sub)
.reduce((acc, { type, value }) => { .reduce((acc, { type, value }) => {
if (type === 'user') { if (type === 'user') {
@ -104,7 +117,8 @@ function calcFilter(subName) {
case 'feed': case 'feed':
return { return {
since: value, since: value,
kinds: [1, 2] kinds: [1, 2],
limit: 5000
} }
case 'event': case 'event':
return { return {
@ -117,6 +131,7 @@ function calcFilter(subName) {
} }
} }
}) })
console.log('filter', subName, filter)
return filter return filter
} }
@ -124,17 +139,22 @@ function cancelSub(id) {
let cancelledSub = subs[id] let cancelledSub = subs[id]
delete subs[id] delete subs[id]
if (!active) return if (!active) return
if (cancelledSub.subName === 'mainUser') { if (!cancelledSub.subName) cancelledSub.subName = 'adhoc'
if (mainUserSub) { if (poolSubs[cancelledSub.subName]) {
if (Object.keys(subs).filter(id => cancelledSub.subName === 'mainUser').length === 0) mainUserSub.unsub() if (Object.keys(subs).filter(id => cancelledSub.subName === (subs[id].subName || 'adhoc')).length === 0) poolSubs[cancelledSub.subName].unsub()
else mainUserSub.sub({filter: calcFilter('mainUser')}) else poolSubs[cancelledSub.subName].sub({filter: calcFilter(cancelledSub.subName)})
}
} else {
if (adhocSub) {
if (Object.keys(subs).filter(id => !cancelledSub.subName).length === 0) adhocSub.unsub()
else adhocSub.sub({filter: calcFilter()})
}
} }
// if (cancelledSub.subName === 'mainUser') {
// if (mainUserSub) {
// if (Object.keys(subs).filter(id => cancelledSub.subName === 'mainUser').length === 0) mainUserSub.unsub()
// else mainUserSub.sub({filter: calcFilter('mainUser')})
// }
// } else {
// if (adhocSub) {
// if (Object.keys(subs).filter(id => !cancelledSub.subName).length === 0) adhocSub.unsub()
// else adhocSub.sub({filter: calcFilter()})
// }
// }
} }
const methods = { const methods = {
@ -145,15 +165,18 @@ const methods = {
activateSub() { activateSub() {
active = true active = true
if (mainUserSub && Object.keys(subs).filter(id => subs[id].subName === 'mainUser').length) mainUserSub.sub({filter: calcFilter('mainUser')}) Object.keys(poolSubs).forEach(subName => poolSubs[subName].sub({filter: calcFilter(subName)}))
if (adhocSub && Object.keys(subs).filter(id => !subs[id].subName).length) adhocSub.sub({filter: calcFilter()}) // if (mainUserSub && Object.keys(subs).filter(id => subs[id].subName === 'mainUser').length) mainUserSub.sub({filter: calcFilter('mainUser')})
// if (adhocSub && Object.keys(subs).filter(id => !subs[id].subName).length) adhocSub.sub({filter: calcFilter()})
return return
}, },
deactivateSub() { deactivateSub() {
active = false active = false
if (mainUserSub) mainUserSub.unsub() Object.keys(poolSubs).forEach(subName => poolSubs[subName].unsub())
if (adhocSub) adhocSub.unsub() // if (mainUserSub) mainUserSub.unsub()
// if (adhocSub) adhocSub.unsub()
// emitEvents(debouncedEvents)
debouncedEmitEvent.flush() debouncedEmitEvent.flush()
return return
}, },
@ -229,7 +252,7 @@ const methods = {
return { return {
type: 'feed', type: 'feed',
value: Math.max(since, 0), value: Math.max(since, 0),
subName: 'mainUser' subName: 'mainFeed'
} }
}, },
@ -285,13 +308,16 @@ function handleMessage(ev) {
} else if (sub) { } else if (sub) {
subs[id] = methods[name](...args) subs[id] = methods[name](...args)
if (!active) return if (!active) return
if (subs[id].subName === 'mainUser') { let subName = subs[id].subName || 'adhoc'
if (mainUserSub) mainUserSub.sub({filter: calcFilter('mainUser')}) if (poolSubs[subName]) poolSubs[subName].sub({filter: calcFilter(subName)})
else mainUserSub = pool.sub({ cb: onEvent, filter: calcFilter('mainUser')}, 'mainUser', onEose) else poolSubs[subName] = pool.sub({ cb: onEvent, filter: calcFilter(subName)}, subName, onEose)
} else { // if (subs[id].subName === 'mainUser') {
if (adhocSub) adhocSub.sub({filter: calcFilter()}) // if (mainUserSub) mainUserSub.sub({filter: calcFilter('mainUser')})
else adhocSub = pool.sub({ cb: onEvent, filter: calcFilter()}, 'adhoc', onEose) // else mainUserSub = pool.sub({ cb: onEvent, filter: calcFilter('mainUser')}, 'mainUser', onEose)
} // } else {
// if (adhocSub) adhocSub.sub({filter: calcFilter()})
// else adhocSub = pool.sub({ cb: onEvent, filter: calcFilter()}, 'adhoc', onEose)
// }
} else { } else {
var reply = { id } var reply = { id }
let data let data

View File

@ -111,7 +111,7 @@ export async function restartMainSubscription(store) {
// setup pool // setup pool
let relays = Object.keys(store.state.relays).length ? store.state.relays : store.state.defaultRelays let relays = Object.keys(store.state.relays).length ? store.state.relays : store.state.defaultRelays
await setRelays(relays, lastUserMainSync - (7 * 24 * 60 * 60)) await setRelays(relays, lastUserMainSync - (1 * 24 * 60 * 60))
// sub to bot tracker follows (to filter out bots in feed) // sub to bot tracker follows (to filter out bots in feed)
let botTracker = '29f63b70d8961835b14062b195fc7d84fa810560b36dde0749e4bc084f0f8952' let botTracker = '29f63b70d8961835b14062b195fc7d84fa810560b36dde0749e4bc084f0f8952'
@ -121,7 +121,7 @@ export async function restartMainSubscription(store) {
}, 60 * 1000) }, 60 * 1000)
// sub feed // sub feed
if (!mainSub.streamFeed) mainSub.streamFeed = await streamFeed(Math.round(Date.now() / 1000) - (5 * 24 * 60 * 60)) if (!mainSub.streamFeed) mainSub.streamFeed = await streamFeed(Math.round(Date.now() / 1000) - (1 * 24 * 60 * 60))
// thats all if no pubkey entered // thats all if no pubkey entered
if (!store.state.keys.pub) return if (!store.state.keys.pub) return
@ -133,7 +133,7 @@ export async function restartMainSubscription(store) {
let config = LocalStorage.getItem('config') || {} let config = LocalStorage.getItem('config') || {}
config.timestamps = {lastUserMainSync: Object.keys(store.state.relays).length ? Math.round(Date.now() / 1000) : 0 } config.timestamps = {lastUserMainSync: Object.keys(store.state.relays).length ? Math.round(Date.now() / 1000) : 0 }
LocalStorage.set('config', config) LocalStorage.set('config', config)
}, 3 * 60 * 1000) }, 5 * 60 * 1000)
if (store.state.follows.length) if (store.state.follows.length)
store.state.follows.forEach(pubkey => store.dispatch('useProfile', {pubkey})) store.state.follows.forEach(pubkey => store.dispatch('useProfile', {pubkey}))
@ -369,25 +369,19 @@ export async function recommendRelay(store, url) {
const debouncedStreamUserProfile = debounce(async (store, users) => { const debouncedStreamUserProfile = debounce(async (store, users) => {
if (!mainSub.streamUserProfile) { if (!mainSub.streamUserProfile) {
mainSub.streamUserProfile = await streamUserProfile( mainSub.streamUserProfile = await streamUserProfile(
users, users.slice(0, 500),
async event => { async event => {
if (event.pubkey in store.state.profilesCache) return if (event.pubkey in store.state.profilesCache) return
let metadata = metadataFromEvent(event) let metadata = metadataFromEvent(event)
store.commit('addProfileToCache', metadata) store.commit('addProfileToCache', metadata)
store.dispatch('useNip05', {metadata}) store.dispatch('useNip05', {metadata})
store.dispatch('cancelUseProfile', {pubkey: event.pubkey})
} }
) )
} else { } else {
if (Object.keys(users).length > 500) { mainSub.streamUserProfile.update(users.slice(0, 500))
for (let pubkey of users) {
if (pubkey in store.state.profilesCache) {
store.dispatch('cancelUseProfile', {pubkey})
}
}
}
mainSub.streamUserProfile.update(users)
} }
}, 100) }, 3000)
let profilesInUse = {} let profilesInUse = {}
export async function useProfile(store, {pubkey}) { export async function useProfile(store, {pubkey}) {
@ -403,18 +397,21 @@ export async function useProfile(store, {pubkey}) {
if (event) { if (event) {
let metadata = metadataFromEvent(event) let metadata = metadataFromEvent(event)
store.dispatch('useNip05', {metadata}) store.dispatch('useNip05', {metadata})
} else {
profilesInUse[pubkey] = profilesInUse[pubkey] || { count: 0, since: Date.now() }
profilesInUse[pubkey].count++
for (let pubkey of Object.keys(profilesInUse)) {
if (profilesInUse[pubkey].since && profilesInUse[pubkey].since < Date.now() - (0.5 * 60 * 1000)) delete profilesInUse[pubkey]
}
if (profilesInUse[pubkey].count === 1) debouncedStreamUserProfile(store, Object.keys(profilesInUse))
} }
} }
profilesInUse[pubkey] = profilesInUse[pubkey] || 0
profilesInUse[pubkey]++
if (profilesInUse[pubkey] === 1) debouncedStreamUserProfile(store, Object.keys(profilesInUse))
} }
export async function cancelUseProfile(store, {pubkey}) { export async function cancelUseProfile(store, {pubkey}) {
if (!profilesInUse[pubkey]) return if (!profilesInUse[pubkey]) return
profilesInUse[pubkey]-- profilesInUse[pubkey].count--
if (profilesInUse[pubkey] === 0) { if (profilesInUse[pubkey].count <= 0) {
delete profilesInUse[pubkey] delete profilesInUse[pubkey]
debouncedStreamUserProfile(store, Object.keys(profilesInUse)) debouncedStreamUserProfile(store, Object.keys(profilesInUse))
} }

View File

@ -10,6 +10,7 @@ const mainnetDefaultRelays = {
'wss://relay.damus.io': {read: true, write: true}, 'wss://relay.damus.io': {read: true, write: true},
'wss://nostr.zebedee.cloud': {read: true, write: false}, 'wss://nostr.zebedee.cloud': {read: true, write: false},
'wss://relay.nostr.info': {read: true, write: false}, 'wss://relay.nostr.info': {read: true, write: false},
'wss://nostr-pub.semisol.dev': {read: true, write: false},
} }
// const default = [ // const default = [
// ['wss://nostr.rocks', {read: true, write: true}], // ['wss://nostr.rocks', {read: true, write: true}],
@ -26,6 +27,7 @@ const mainnetDefaultRelays = {
'wss://nostr.rocks', 'wss://nostr.rocks',
'wss://rsslay.fiatjaf.com', 'wss://rsslay.fiatjaf.com',
'wss://nostr.zebedee.cloud', 'wss://nostr.zebedee.cloud',
'wss://nostr-2.zebedee.cloud',
'wss://expensive-relay.fiatjaf.com', 'wss://expensive-relay.fiatjaf.com',
'wss://freedom-relay.herokuapp.com/ws', 'wss://freedom-relay.herokuapp.com/ws',
'wss://nostr-relay.freeberty.net', 'wss://nostr-relay.freeberty.net',

View File

@ -1,3 +1,5 @@
import * as DOMPurify from 'dompurify'
export function cleanEvent(event) { export function cleanEvent(event) {
return { return {
id: event.id, id: event.id,
@ -13,6 +15,7 @@ export function cleanEvent(event) {
export function metadataFromEvent(event) { export function metadataFromEvent(event) {
try { try {
let metadata = JSON.parse(event.content) let metadata = JSON.parse(event.content)
for (let key of Object.keys(metadata)) metadata[key] = DOMPurify.sanitize(metadata[key])
metadata.pubkey = event.pubkey metadata.pubkey = event.pubkey
return metadata return metadata
} catch (_) { } catch (_) {

View File

@ -1,7 +1,7 @@
import {dbUserProfile, dbEvent} from '../query' import {dbUserProfile, dbEvent} from '../query'
export function shorten(str) { export function shorten(str, number = 5) {
return str ? str.slice(0, 5) + '…' + str.slice(-5) : '' return str ? str.slice(0, number) + '…' + str.slice(-(number)) : ''
} }
export function getElementFullHeight(element) { export function getElementFullHeight(element) {

View File

@ -5,6 +5,7 @@ import {date} from 'quasar'
import { dbStreamEvent } from 'src/query' import { dbStreamEvent } from 'src/query'
import {decrypt} from 'nostr-tools/nip04' import {decrypt} from 'nostr-tools/nip04'
import { decode } from 'bech32-buffer' import { decode } from 'bech32-buffer'
import * as DOMPurify from 'dompurify'
const { formatDate } = date const { formatDate } = date
@ -119,8 +120,8 @@ export default {
return `[@${displayName}](/${profile})` return `[@${displayName}](/${profile})`
} }
} }
const hashtagReplacer = (match, hashtag) => { const hashtagReplacer = (match, startWhitespace, hashtag) => {
return `[${match}](/hashtag/${hashtag})` return `${startWhitespace}[${match}](/hashtag/${hashtag})`
} }
const untaggedProfileReplacer = (match, profile) => { const untaggedProfileReplacer = (match, profile) => {
const displayName = this.$store.getters.displayName(profile) const displayName = this.$store.getters.displayName(profile)
@ -128,7 +129,7 @@ export default {
} }
let replacedText = text.replace(/#\[(\d+)\]/g, replacer) let replacedText = text.replace(/#\[(\d+)\]/g, replacer)
let hashtagReplacedText = replacedText.replace(/#([\w]{1,63})/g, hashtagReplacer) let hashtagReplacedText = replacedText.replace(/(?<s>[\s]?)#([\w]{1,63})\b/g, hashtagReplacer)
let untaggedProfileReplacedText = hashtagReplacedText.replace(/@([\w]{64})/g, untaggedProfileReplacer) let untaggedProfileReplacedText = hashtagReplacedText.replace(/@([\w]{64})/g, untaggedProfileReplacer)
let replacedTextFinal = untaggedProfileReplacedText let replacedTextFinal = untaggedProfileReplacedText
@ -154,7 +155,7 @@ export default {
}) })
return { return {
text: replacedTextFinal, text: DOMPurify.sanitize(replacedTextFinal),
replyEvents: mentions.replyEvents, replyEvents: mentions.replyEvents,
mentionEvents: mentions.mentionEvents mentionEvents: mentions.mentionEvents
} }

View File

@ -2434,6 +2434,11 @@ bech32-buffer@^0.2.0:
resolved "https://registry.npmjs.org/bech32-buffer/-/bech32-buffer-0.2.0.tgz" resolved "https://registry.npmjs.org/bech32-buffer/-/bech32-buffer-0.2.0.tgz"
integrity sha512-Ez8s82a+Xnn/m3/ftGaQJUSFG4EwNIj9adIJBw8OrHASQsXgvwLSducbcJ9El0rsrwJYJ71yBhC/hZzz3FPSCQ== integrity sha512-Ez8s82a+Xnn/m3/ftGaQJUSFG4EwNIj9adIJBw8OrHASQsXgvwLSducbcJ9El0rsrwJYJ71yBhC/hZzz3FPSCQ==
bech32@^1.1.2:
version "1.1.4"
resolved "https://registry.yarnpkg.com/bech32/-/bech32-1.1.4.tgz#e38c9f37bf179b8eb16ae3a772b40c356d4832e9"
integrity sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==
big.js@^5.2.2: big.js@^5.2.2:
version "5.2.2" version "5.2.2"
resolved "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz" resolved "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz"
@ -2453,6 +2458,11 @@ bl@^4.0.3, bl@^4.1.0:
inherits "^2.0.4" inherits "^2.0.4"
readable-stream "^3.4.0" readable-stream "^3.4.0"
bn.js@^4.11.8:
version "4.12.0"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==
body-parser@1.20.0: body-parser@1.20.0:
version "1.20.0" version "1.20.0"
resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz" resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz"
@ -2574,7 +2584,7 @@ buffer-xor@^1.0.3:
resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9"
integrity sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ== integrity sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==
buffer@>=5: buffer@>=5, buffer@^6.0.3:
version "6.0.3" version "6.0.3"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==
@ -3288,6 +3298,11 @@ domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1:
dependencies: dependencies:
domelementtype "^2.2.0" domelementtype "^2.2.0"
dompurify@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.1.tgz#f9cb1a275fde9af6f2d0a2644ef648dd6847b631"
integrity sha512-ewwFzHzrrneRjxzmK6oVz/rZn9VWspGFRDb4/rRtIsM1n36t9AKma/ye8syCpcw+XJ25kOK/hOG7t1j2I2yBqA==
domutils@^2.5.2, domutils@^2.8.0: domutils@^2.5.2, domutils@^2.8.0:
version "2.8.0" version "2.8.0"
resolved "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz" resolved "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz"
@ -4774,6 +4789,15 @@ levn@^0.4.1:
prelude-ls "^1.2.1" prelude-ls "^1.2.1"
type-check "~0.4.0" type-check "~0.4.0"
light-bolt11-decoder@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/light-bolt11-decoder/-/light-bolt11-decoder-2.1.0.tgz#46b122790ae0eb415841227eba770eae7303ecf5"
integrity sha512-/AaSWTldx3aaFD7DgMVbX77MVEgLEPI0Zyx4Fjg23u3WpEoc536vz5LTXBU8oXAcrEcyDyn5GpBi2pEYuL351Q==
dependencies:
bech32 "^1.1.2"
bn.js "^4.11.8"
buffer "^6.0.3"
lilconfig@^2.0.3: lilconfig@^2.0.3:
version "2.0.6" version "2.0.6"
resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz" resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz"
@ -5188,6 +5212,11 @@ natural-compare@^1.4.0:
resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
nayuki-qr-code-generator@^1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/nayuki-qr-code-generator/-/nayuki-qr-code-generator-1.8.0.tgz#dbeebd7b3d2b53119c51a596ad43199ef6798916"
integrity sha512-wnpXdJ+zJ+8QzzGJTEnESaRYfNIUHSgX4ykvYuaomqqUbhJdun3v0sK/39BQjw3on/vf7ujPKIg2V07WejjmLw==
negotiator@0.6.3: negotiator@0.6.3:
version "0.6.3" version "0.6.3"
resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz" resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz"