big refactor on core logic.

This commit is contained in:
fiatjaf 2021-12-10 21:44:58 -03:00
parent 598dd7459e
commit c04c4d7b52
21 changed files with 329 additions and 1045 deletions

View File

@ -11,12 +11,12 @@
}, },
"dependencies": { "dependencies": {
"@quasar/extras": "^1.0.0", "@quasar/extras": "^1.0.0",
"bip32": "^3.0.1",
"bip39": "^3.0.4", "bip39": "^3.0.4",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"crypto": "^1.0.1", "crypto": "^1.0.1",
"identicon.js": "^2.3.3", "identicon.js": "^2.3.3",
"md-gum-polyfill": "^1.0.0", "nostr-tools": "^0.6.0",
"nostr-tools": "^0.5.0",
"quasar": "^2.0.0", "quasar": "^2.0.0",
"stream": "^0.0.2", "stream": "^0.0.2",
"vuex": "^4.0.1" "vuex": "^4.0.1"

View File

@ -8,7 +8,7 @@
/* eslint-env node */ /* eslint-env node */
const ESLintPlugin = require('eslint-webpack-plugin') const ESLintPlugin = require('eslint-webpack-plugin')
const { configure } = require('quasar/wrappers'); const {configure} = require('quasar/wrappers')
module.exports = configure(function (ctx) { module.exports = configure(function (ctx) {
return { return {
@ -21,13 +21,10 @@ module.exports = configure(function (ctx) {
// app boot file (/src/boot) // app boot file (/src/boot)
// --> boot files are part of "main.js" // --> boot files are part of "main.js"
// https://quasar.dev/quasar-cli/boot-files // https://quasar.dev/quasar-cli/boot-files
boot: [ boot: [],
],
// https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-css // https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-css
css: [ css: ['app.scss'],
'app.scss'
],
// https://github.com/quasarframework/quasar/tree/dev/extras // https://github.com/quasarframework/quasar/tree/dev/extras
extras: [ extras: [
@ -40,15 +37,15 @@ module.exports = configure(function (ctx) {
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both! // 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
'roboto-font', // optional, you are not bound to it 'roboto-font', // optional, you are not bound to it
'material-icons', // optional, you are not bound to it 'material-icons' // optional, you are not bound to it
], ],
// Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-build // Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-build
build: { build: {
vueRouterMode: 'hash', // available values: 'hash', 'history' vueRouterMode: 'history', // available values: 'hash', 'history'
// transpile: false, // transpile: false,
// publicPath: '/', publicPath: '/',
// Add dependencies for transpiling with Babel (Array of string/regex) // Add dependencies for transpiling with Babel (Array of string/regex)
// (from node_modules, which are by default not transpiled). // (from node_modules, which are by default not transpiled).
@ -66,10 +63,11 @@ module.exports = configure(function (ctx) {
// https://quasar.dev/quasar-cli/handling-webpack // https://quasar.dev/quasar-cli/handling-webpack
// "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain // "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain
chainWebpack (chain) { chainWebpack(chain) {
chain.plugin('eslint-webpack-plugin') chain
.use(ESLintPlugin, [{ extensions: [ 'js', 'vue' ] }]) .plugin('eslint-webpack-plugin')
}, .use(ESLintPlugin, [{extensions: ['js', 'vue']}])
}
}, },
// Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-devServer // Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-devServer
@ -111,14 +109,15 @@ module.exports = configure(function (ctx) {
// manualPostHydrationTrigger: true, // manualPostHydrationTrigger: true,
prodPort: 3000, // The default port that the production server should use prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime) // (gets superseded if process.env.PORT is specified at runtime)
maxAge: 1000 * 60 * 60 * 24 * 30, maxAge: 1000 * 60 * 60 * 24 * 30,
// Tell browser when a file from the server should expire from cache (in ms) // Tell browser when a file from the server should expire from cache (in ms)
chainWebpackWebserver (chain) { chainWebpackWebserver(chain) {
chain.plugin('eslint-webpack-plugin') chain
.use(ESLintPlugin, [{ extensions: [ 'js' ] }]) .plugin('eslint-webpack-plugin')
.use(ESLintPlugin, [{extensions: ['js']}])
}, },
middlewares: [ middlewares: [
@ -134,9 +133,10 @@ module.exports = configure(function (ctx) {
// for the custom service worker ONLY (/src-pwa/custom-service-worker.[js|ts]) // for the custom service worker ONLY (/src-pwa/custom-service-worker.[js|ts])
// if using workbox in InjectManifest mode // if using workbox in InjectManifest mode
chainWebpackCustomSW (chain) { chainWebpackCustomSW(chain) {
chain.plugin('eslint-webpack-plugin') chain
.use(ESLintPlugin, [{ extensions: [ 'js' ] }]) .plugin('eslint-webpack-plugin')
.use(ESLintPlugin, [{extensions: ['js']}])
}, },
manifest: { manifest: {
@ -193,13 +193,11 @@ module.exports = configure(function (ctx) {
packager: { packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options // https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// OS X / Mac App Store // OS X / Mac App Store
// appBundleId: '', // appBundleId: '',
// appCategoryType: '', // appCategoryType: '',
// osxSign: '', // osxSign: '',
// protocol: 'myapp://path', // protocol: 'myapp://path',
// Windows only // Windows only
// win32metadata: { ... } // win32metadata: { ... }
}, },
@ -211,16 +209,18 @@ module.exports = configure(function (ctx) {
}, },
// "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain // "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain
chainWebpackMain (chain) { chainWebpackMain(chain) {
chain.plugin('eslint-webpack-plugin') chain
.use(ESLintPlugin, [{ extensions: [ 'js' ] }]) .plugin('eslint-webpack-plugin')
.use(ESLintPlugin, [{extensions: ['js']}])
}, },
// "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain // "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain
chainWebpackPreload (chain) { chainWebpackPreload(chain) {
chain.plugin('eslint-webpack-plugin') chain
.use(ESLintPlugin, [{ extensions: [ 'js' ] }]) .plugin('eslint-webpack-plugin')
}, .use(ESLintPlugin, [{extensions: ['js']}])
}
} }
} }
}); })

View File

@ -1,10 +1,8 @@
import Generate from '../components/Generate.vue'
import Publish from '../components/Publish.vue' import Publish from '../components/Publish.vue'
import Reply from '../components/Reply.vue' import Reply from '../components/Reply.vue'
import Post from '../components/Post.vue' import Post from '../components/Post.vue'
export default ({app}) => { export default ({app}) => {
app.component('Generate', Generate)
app.component('Publish', Publish) app.component('Publish', Publish)
app.component('Reply', Reply) app.component('Reply', Reply)
app.component('Post', Post) app.component('Post', Post)

View File

@ -1,222 +0,0 @@
<template>
<q-card class="q-pa-md q-pt-lg q-mt-md">
<q-stepper v-model="step" vertical color="primary" animated>
<q-step
:name="1"
title="Generate/Restore"
icon="settings"
:done="step > 1"
>
Nostr.org uses a word list of 12 words is used to create your keys, to
restore either enter a word list or a Nostr private key.
<q-input
v-model="recover"
:loading="loading"
autogrow
type="textarea"
label="Word List/Private Key"
></q-input
><br />
<q-btn
color="primary"
label="Generate"
class="q-mr-md"
@click="createKeys"
/>
<q-btn
color="primary"
label="Restore"
class="q-mr-md"
@click="createKeys"
/>
<q-btn
v-if="privatekey"
color="primary"
label="Continue"
@click="step = 2"
/>
</q-step>
<q-step :name="2" title="Your keys" icon="vpn_key" :done="step > 2">
In this client you can restore from a word list but for other clients
you will need to use your keys as well.<br /><br />
Your private key is used to sign/publish posts.
<br />
<q-input
v-model="privatekey"
filled
:type="isPwd ? 'password' : 'text'"
>
<template #prepend>
<q-icon
name="content_copy"
class="cursor-pointer"
@click="copyToClip(privatekey)"
></q-icon>
</template>
<template #append>
<q-icon
:name="isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="isPwd = !isPwd"
></q-icon>
</template>
</q-input>
<br />
Your public key allows other people to read your posts, follow you, and
send you private messages.
<br />
<q-input v-model="publickey" filled type="text">
<template #prepend>
<q-icon
name="content_copy"
class="cursor-pointer"
@click="copyToClip(publickey)"
></q-icon>
</template>
</q-input>
<q-stepper-navigation>
<q-btn color="primary" label="Continue" @click="step = 3" />
<q-btn
flat
color="primary"
label="Back"
class="q-ml-sm"
@click="step = 1"
/>
</q-stepper-navigation>
</q-step>
<q-step :name="3" title="Key storage" icon="lock">
To publish your posts this client needs to sign messages with your
private key. Choose how this client will access your private key.
<div class="q-pa-md q-gutter-sm">
<div class="q-gutter-sm">
<q-radio
v-model="keystoreoption"
dense
val="local"
label="Local Storage (Recommended)"
/><br />
<q-radio
v-model="keystoreoption"
dense
disable
val="url"
label="URL (coming soon)"
/><br />
<q-radio
v-model="keystoreoption"
dense
disable
val="external"
label="Hardware wallet (coming soon)"
/><br />
</div>
</div>
<q-stepper-navigation>
<q-btn color="primary" label="Finish" @click="finalGenerate" />
<q-btn
flat
color="primary"
label="Back"
class="q-ml-sm"
@click="step = 2"
/>
</q-stepper-navigation>
</q-step>
</q-stepper>
</q-card>
</template>
<script>
import crypto from 'crypto'
import {getPublicKey} from 'nostr-tools'
import {copyToClipboard} from 'quasar'
import helpersMixin from '../utils/mixin'
const bip39 = require('bip39')
const bip32 = require('bip32')
export default {
mixins: [helpersMixin],
data() {
return {
step: 1,
loading: false,
recover: '',
privatekey: null,
publickey: null,
keystoreoption: 'local',
isPwd: true
}
},
methods: {
async createKeys() {
this.loading = true
this.recover = this.recover.trim()
setTimeout(() => {
if (this.recover.split(/ +/).length === 12) {
// recover mnemonic
let mnemonic = this.recover.split(/ +/).join(' ')
let seed = bip39.mnemonicToSeedSync(mnemonic)
let root = bip32.fromSeed(seed)
this.privatekey = root.privateKey.toString('hex')
this.recover = mnemonic
} else if (/^[0-9a-f]{64}$/.exec(this.recover.toLowerCase())) {
// recover private key
this.privatekey = this.recover.toLowerCase()
this.recover = this.privatekey
} else {
// generate a new seed
let randomBytes = crypto.randomBytes(16)
let mnemonic = bip39.entropyToMnemonic(randomBytes.toString('hex'))
let seed = bip39.mnemonicToSeedSync(mnemonic)
let root = bip32.fromSeed(seed)
this.privatekey = root.privateKey.toString('hex')
this.$q.notify({
message: 'MAKE SURE YOU BACKUP YOUR WORD LIST'
})
this.recover = mnemonic
}
this.publickey = getPublicKey(this.privatekey)
this.loading = false
}, 1)
},
copyToClip(text) {
copyToClipboard(text)
.then(() => {
this.$q.notify({
message: 'COPIED'
})
})
.catch(() => {
this.$q.notify({type: 'negative', message: 'FAILED'})
})
},
finalGenerate() {
this.$store.dispatch('finalGenerate', {
privatekey: this.privatekey,
publickey: this.publickey,
keystoreoption: this.keystoreoption
})
if (this.keystoreoption === 'external') {
this.$router.push('/?pub=' + this.publickey + '&prv=' + this.privatekey)
} else {
this.$router.push('/')
}
}
}
}
</script>

View File

@ -17,7 +17,7 @@
<q-card-section class="col no-shadow"> <q-card-section class="col no-shadow">
<q-card-section class="q-pa-none" @click="dialogReply = true"> <q-card-section class="q-pa-none" @click="dialogReply = true">
<q-item-label <q-item-label
>{{ $store.getters.handle(post.pubkey) }} >{{ $store.getters.displayName(post.pubkey) }}
<small style="color: grey"> <small style="color: grey">
{{ niceDate(post.created_at * 1000) }} {{ niceDate(post.created_at * 1000) }}
</small> </small>
@ -38,29 +38,6 @@
@click="dialogReply = true" @click="dialogReply = true"
> >
</q-btn> </q-btn>
<q-btn
v-if="post.retry"
class="float-right q-mr-xs"
round
unelevated
color="pink"
flat
icon="settings_backup_restore"
size="sm"
@click="postAgain(post)"
/>
<q-btn
v-if="post.retry"
class="float-right q-mr-xs"
round
unelevated
color="pink"
flat
icon="cancel"
size="sm"
@click="deletePost(post)"
/>
</div> </div>
</q-card-section> </q-card-section>
</q-card-section> </q-card-section>
@ -77,15 +54,6 @@ export default {
return { return {
dialogReply: false dialogReply: false
} }
},
methods: {
postAgain(post) {
this.$store.dispatch('postAgain', post)
},
deletePost(post) {
this.$store.dispatch('deletePost', post.id)
}
} }
} }
</script> </script>

View File

@ -3,17 +3,15 @@
<div class="row"> <div class="row">
<q-form style="width: 100%" class="q-gutter-md" @submit="sendPost"> <q-form style="width: 100%" class="q-gutter-md" @submit="sendPost">
<q-input <q-input
v-model="publishtext" v-model="text"
style="font-size: 20px" style="font-size: 20px"
label="Say something" label="Say something"
maxlength="280" maxlength="280"
> >
<template #before> <template #before>
<q-btn round @click="toProfile($store.state.myProfile.pubkey)"> <q-btn round @click="toProfile($store.state.keys.pub)">
<q-avatar size="42px"> <q-avatar size="42px">
<img <img :src="$store.getters.avatar($store.state.keys.pub)" />
:src="$store.getters.avatar($store.state.myProfile.pubkey)"
/>
</q-avatar> </q-avatar>
</q-btn> </q-btn>
</template> </template>
@ -21,7 +19,7 @@
<div class="float-right"> <div class="float-right">
<q-btn <q-btn
v-if="publishtext.length < 280" v-if="text.length < 280"
class="float-left q-mr-md" class="float-left q-mr-md"
round round
unelevated unelevated
@ -37,7 +35,7 @@
rounded rounded
unelevated unelevated
dense dense
@click="publishtext = publishtext + emoji.item" @click="text = text + emoji.item"
>{{ emoji.item }}</q-btn >{{ emoji.item }}</q-btn
> >
<br /> <br />
@ -48,7 +46,7 @@
rounded rounded
unelevated unelevated
dense dense
@click="publishtext = publishtext + emoji.item" @click="text = text + emoji.item"
>{{ emoji.item }}</q-btn >{{ emoji.item }}</q-btn
> >
</q-popup-proxy> </q-popup-proxy>
@ -87,13 +85,13 @@ export default {
data() { data() {
return { return {
publishtext: '' text: ''
} }
}, },
methods: { methods: {
sendPost() { sendPost() {
this.$store.dispatch('sendPost', {message: this.publishtext}) this.$store.dispatch('sendPost', {message: this.text})
this.publishtext = '' this.text = ''
} }
} }
} }

View File

@ -12,7 +12,7 @@
<q-card-section class="col no-shadow q-pb-none"> <q-card-section class="col no-shadow q-pb-none">
<q-item-label <q-item-label
>{{ $store.getters.handle(post.pubkey) }} >{{ $store.getters.displayName(post.pubkey) }}
<small style="color: grey">{{ niceDate(post.created_at) }}</small> <small style="color: grey">{{ niceDate(post.created_at) }}</small>
</q-item-label> </q-item-label>
{{ post.content }} {{ post.content }}
@ -25,11 +25,11 @@
<q-form <q-form
style="width: 100%" style="width: 100%"
class="q-gutter-md" class="q-gutter-md"
@submit="sendReply(replytext, [['e', post.id]])" @submit="sendReply(text, [['e', post.id]])"
><q-tooltip> Coming soon </q-tooltip> ><q-tooltip> Coming soon </q-tooltip>
<q-input <q-input
disable disable
v-model="replytext" v-model="text"
dense dense
style="font-size: 20px" style="font-size: 20px"
autogrow autogrow
@ -40,7 +40,7 @@
<div class="float-right"> <div class="float-right">
<q-btn <q-btn
disable disable
v-if="replytext.length < 280" v-if="text.length < 280"
class="float-left q-mr-md" class="float-left q-mr-md"
round round
unelevated unelevated
@ -56,7 +56,7 @@
rounded rounded
unelevated unelevated
dense dense
@click="replytext = replytext + emoji.item" @click="text = text + emoji.item"
>{{ emoji.item }}</q-btn >{{ emoji.item }}</q-btn
> >
<br /> <br />
@ -67,7 +67,7 @@
rounded rounded
unelevated unelevated
dense dense
@click="replytext = replytext + emoji.item" @click="text = text + emoji.item"
>{{ emoji.item }}</q-btn >{{ emoji.item }}</q-btn
> >
</q-popup-proxy> </q-popup-proxy>
@ -109,18 +109,16 @@ export default {
props: ['post'], props: ['post'],
data() { data() {
return { return {
publishtext: '', text: ''
replytext: ''
} }
}, },
methods: { methods: {
sendReply() { sendReply() {
console.log(this.post.id)
this.$store.dispatch('sendPost', { this.$store.dispatch('sendPost', {
message: this.replytext, message: this.text,
tags: [['e', this.post.id]] tags: [['e', this.post.id]]
}) })
this.replytext = '' this.text = ''
} }
} }
} }

View File

@ -4,10 +4,6 @@
<Publish /> <Publish />
</q-dialog> </q-dialog>
<q-dialog v-model="dialogGenerate" position="top">
<Generate />
</q-dialog>
<div class="flex-center column"> <div class="flex-center column">
<div class="row" style="width: 100%"> <div class="row" style="width: 100%">
<div <div
@ -106,22 +102,6 @@
<q-item-section>Settings</q-item-section> <q-item-section>Settings</q-item-section>
</q-item> </q-item>
<q-item
v-ripple
clickable
:active="$route.name === 'help'"
active-class="my-menu-link"
style="padding: 15px"
:to="'/help'"
>
<q-item-section avatar>
<q-icon name="help"></q-icon>
</q-item-section>
<q-item-section>Help</q-item-section>
</q-item>
<br />
</q-list> </q-list>
<q-btn <q-btn
v-if="!$store.getters.disabled" v-if="!$store.getters.disabled"
@ -151,8 +131,8 @@
color="primary" color="primary"
size="md" size="md"
dense dense
:label="$store.getters.handle($store.state.myProfile.pubkey)" :label="$store.getters.displayName($store.state.keys.pub)"
@click="copyToClip($store.state.myProfile.pubkey)" @click="copyPubKey"
> >
<q-tooltip> Copy public key </q-tooltip></q-btn <q-tooltip> Copy public key </q-tooltip></q-btn
> >
@ -203,13 +183,11 @@
</q-input> </q-input>
</q-form> </q-form>
</q-card-section> </q-card-section>
<q-card-section <q-card-section v-if="$store.state.following.length">
v-if="Object.keys($store.state.theirProfile).length"
>
<h6 class="q-ma-none">Following</h6> <h6 class="q-ma-none">Following</h6>
<q-list> <q-list>
<q-item <q-item
v-for="(_, pubkey) in $store.state.theirProfile" v-for="(_, pubkey) in $store.state.following"
:key="pubkey" :key="pubkey"
v-ripple v-ripple
clickable clickable
@ -222,7 +200,7 @@
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
{{ $store.getters.handle(pubkey) }} {{ $store.getters.displayName(pubkey) }}
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
@ -234,44 +212,7 @@
</div> </div>
<q-footer bordered style="bottom: 0%; position: fixed" class="bg-white"> <q-footer bordered style="bottom: 0%; position: fixed" class="bg-white">
<q-banner <div class="text-center">
v-if="showInstallBanner"
inline-actions
dense
class="bg-primary text-white"
>
<template #avatar>
<q-avatar>
<img src="/icons/favicon-16x16.png" />
</q-avatar>
</template>
<b> INSTALL NOSTR?</b>
<template #action>
<q-btn
flat
dense
class="q-px-sm"
label="Yes"
@click="installApp()"
></q-btn>
<q-btn
flat
dense
class="q-px-sm"
label="Later"
@click="showInstallBanner = false"
></q-btn>
<q-btn
flat
dense
class="q-px-sm"
label="Never"
@click="neverInstallApp()"
></q-btn>
</template>
</q-banner>
<center>
<q-tabs class="text-primary small-screen-only"> <q-tabs class="text-primary small-screen-only">
<q-route-tab style="width: 20%" name="home" icon="home" to="/" /> <q-route-tab style="width: 20%" name="home" icon="home" to="/" />
@ -287,85 +228,39 @@
icon="settings" icon="settings"
to="/settings" to="/settings"
/> />
<q-route-tab style="width: 20%" name="help" icon="help" to="/help" />
</q-tabs> </q-tabs>
</center> </div>
</q-footer> </q-footer>
</q-layout> </q-layout>
</template> </template>
<script> <script>
let deferredPrompt
import {copyToClipboard} from 'quasar' import {copyToClipboard} from 'quasar'
import helpersMixin from '../utils/mixin' import helpersMixin from '../utils/mixin'
export default { export default {
name: 'MainLayout', name: 'MainLayout',
mixins: [helpersMixin], mixins: [helpersMixin],
data() { data() {
return { return {
showInstallBanner: null,
dialogGenerate: false, dialogGenerate: false,
dialogPublish: false, dialogPublish: false,
addPubKey: '' addPubKey: ''
} }
}, },
mounted() {
let value = this.$q.localStorage.getItem('neverShowBanner')
if (!value) {
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault()
deferredPrompt = e
this.showInstallBanner = true
})
}
},
created: function () { created: function () {
if (this.$store.getters.disabled) {
this.$router.push('/help')
return
}
this.$store.dispatch('launch') this.$store.dispatch('launch')
}, },
methods: { methods: {
installApp() { async copyPubKey() {
this.showInstallBanner = false
deferredPrompt.prompt()
deferredPrompt.userChoice.then(choiceResult => {
if (choiceResult.outcome === 'accepted') {
console.log('User accepted the install prompt')
} else {
console.log('User dismissed the install prompt')
}
})
},
neverInstallApp() {
this.showInstallBanner = false
try { try {
this.$q.localStorage.setItem('neverShowBanner', true) await copyToClipboard(this.$store.state.keys.pub)
} catch (e) { this.$q.notify({
console.log(e) message: 'COPIED'
}
},
copyToClip(text) {
copyToClipboard(text)
.then(() => {
this.$q.notify({
message: 'COPIED'
})
}) })
.catch(() => { } catch (err) {
this.$q.notify({type: 'negative', message: 'FAILED'}) this.$q.notify({type: 'negative', message: 'FAILED'})
})
},
addPubFollow() {
if (this.addPubKey.trim() !== this.$store.state.myProfile.pubkey) {
this.$store.dispatch('startFollowing', this.addPubKey.trim())
} else {
this.$q.notify({color: 'pink', message: 'You cant follow yourself!'})
} }
this.addPubKey = ''
} }
} }
} }

View File

@ -1,8 +1,8 @@
<template> <template>
<q-page> <q-page>
<center> <div class="text-center">
<strong class="text-h6 q-pa-lg">Chat</strong> <strong class="text-h6 q-pa-lg">Chat</strong>
</center> </div>
<q-btn <q-btn
v-go-back.single v-go-back.single
flat flat
@ -21,31 +21,28 @@
> >
<q-scroll-area <q-scroll-area
ref="chatScroll" ref="chatScroll"
:thumb-style="thumbStyle" :thumb-style="{
left: '102%',
backgroundColor: '#26A69A',
width: '10px',
opacity: 0.35
}"
style="height: 100%; max-width: 100%" style="height: 100%; max-width: 100%"
> >
<div v-for="message in messages" :key="message.id"> <div
v-for="event in $store.state.events.kind4[$route.params.pubkey] ||
[]"
:key="event.id"
>
<q-chat-message <q-chat-message
:text="[ :text="[event.plaintext]"
message.text + :name="$store.getters.displayName(event.pubkey)"
(message.loading :avatar="$store.getters.avatar(event.pubkey)"
? '<small>sending...</small>' :sent="event.pubkey === $store.state.keys.pub"
: message.failed :stamp="niceDate(new Date(event.created_at * 1000))"
? '<small>failed. <a class=delete><i class=material-icons>cancel</i></a> <a class=retry><i class=material-icons>settings_backup_restore</i></a></small>'
: '')
]"
:name="message.from.substring(0, 6) + '...'"
:avatar="$store.getters.avatar(message.from)"
:sent="
message.from === $store.state.myProfile.pubkey ? true : false
"
:stamp="niceDate(new Date(message.created_at * 1000))"
:bg-color=" :bg-color="
message.from === $store.state.myProfile.pubkey event.pubkey === $store.state.keys.pub ? 'primary' : 'tertiary'
? 'primary'
: 'tertiary'
" "
@click="ev => clickMessageAction(ev, message.id, message.text)"
> >
</q-chat-message> </q-chat-message>
</div> </div>
@ -56,7 +53,7 @@
<q-form <q-form
class="q-gutter-md" class="q-gutter-md"
@submit="submitMessage" @submit="submitMessage"
@reset="resetMessage" @reset="this.text = ''"
> >
<div class="row"> <div class="row">
<div class="col-8"> <div class="col-8">
@ -139,28 +136,7 @@ export default {
data() { data() {
return { return {
text: '', text: ''
reload: 0, // a hack to recompute messages,
thumbStyle: {
left: '102%',
backgroundColor: '#26A69A',
width: '10px',
opacity: 0.35
}
}
},
computed: {
messages() {
this.$store.state.chatUpdated // hack to recompute
this.scroll()
return (
LocalStorage.getItem(`messages.${this.$route.params.pubkey}`).sort(
function (a, b) {
return a.created_at - b.created_at
}
) || []
)
} }
}, },
methods: { methods: {
@ -170,23 +146,7 @@ export default {
const duration = 350 const duration = 350
scrollArea.setScrollPosition(scrollTarget.scrollHeight, duration) scrollArea.setScrollPosition(scrollTarget.scrollHeight, duration)
}, },
async failed() {
var messages = this.$q.localStorage.getItem(
`messages.${this.$route.params.pubkey}`
)
if (messages) {
for (var i = 0; i < messages.length; i++) {
if (messages[i].loading === true) {
messages[i].failed = true
messages[i].loading = false
this.$q.localStorage.set(
`messages.${this.$route.params.pubkey}`,
messages
)
}
}
}
},
async submitMessage() { async submitMessage() {
await this.$store.dispatch('sendChatMessage', { await this.$store.dispatch('sendChatMessage', {
pubkey: this.$route.params.pubkey, pubkey: this.$route.params.pubkey,
@ -194,39 +154,7 @@ export default {
}) })
this.text = '' this.text = ''
this.$store.commit('chatUpdated')
this.scroll() this.scroll()
setTimeout(() => {
this.$store.commit('chatUpdated') // another hack if post fails
this.failed()
}, 2000)
},
clickMessageAction(ev, id, text) {
ev.preventDefault()
var action = ev.target
for (let i = 0; i < 5; i++) {
if (action.classList.contains('retry')) {
this.$store.dispatch('deleteChatMessage', {
pubkey: this.$route.params.pubkey,
id
})
this.text = text
this.submitMessage()
} else if (action.classList.contains('delete')) {
this.$store.dispatch('deleteChatMessage', {
pubkey: this.$route.params.pubkey,
id
})
this.$store.commit('chatUpdated')
}
action = action.parentNode
}
},
resetMessage() {
this.text = ''
} }
} }
} }

View File

@ -1,116 +0,0 @@
<template>
<q-page>
<center>
<strong class="text-h6 q-pa-lg">Help</strong>
</center>
<br />
<q-btn
v-go-back.single
flat
color="white"
icon="arrow_back"
label="back"
class="small-screen-only fixed-top-left"
/>
<br /><br />
<strong>What is Nostr (Notes and other stuff relays)?</strong><br /><br />
<p>
Nostr is a decentralised collection of relays passing data between
clients. Anyone can run a client or relay. This particular 240char limited
client is just one way to send data through Nostr.
</p>
<center>
<img
class="q-px-auto"
style="width: 100%"
src="https://i.imgur.com/NsnaiBP.png"
/>
<br />
</center>
<p>
Nostr uses public key cryptography. Posts are signed with your private key
and people can follow your posts using your public key. Direct messages in
this client are encrypted before being sent through nostr network.
</p>
<p>
<a
href="https://github.com/fiatjaf/nostr"
target="_blank"
style="color: #26a69a"
>Learn more about the Nostr protocol</a
>
</p>
<p>
<a
href="https://github.com/arcbtc/nostr"
target="_blank"
style="color: #26a69a"
>https://github.com/arcbtc/nostr</a
>
</p>
<center>
<q-btn
v-if="$store.getters.disabled"
dense
flat
class="small-screen-only q-pa-lg"
color="primary"
size="md"
label="Generate or Restore User Account"
@click="dialogGenerate = true"
></q-btn>
</center>
<q-dialog v-model="dialogGenerate" position="top">
<Generate />
</q-dialog>
<q-dialog v-if="$store.getters.disabled" v-model="warningPrompt" persistent>
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">Warning!</div>
</q-card-section>
<q-card-section class="q-pt-none">
<p>
This is buggy and experimental software running for testing purposes
ONLY, any data you put on here will be lost!<br />
</p>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" v-close-popup />
<q-btn
flat
label="Proceed"
v-close-popup
@click="warningPrompt = false"
/>
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script>
import helpersMixin from '../utils/mixin'
export default {
name: 'PageHelp',
mixins: [helpersMixin],
data() {
return {
dialogGenerate: false,
warningPrompt: false
}
},
created() {
setTimeout(() => {
this.warningPrompt = true
}, 400)
}
}
</script>

View File

@ -1,8 +1,8 @@
<template> <template>
<q-page> <q-page>
<center> <div class="text-center">
<strong class="text-h6 q-pa-lg">Encrypted Messages</strong> <strong class="text-h6 q-pa-lg">Encrypted Messages</strong>
</center> </div>
<q-btn <q-btn
v-go-back.single v-go-back.single
flat flat
@ -20,7 +20,7 @@
<div class="q-mx-auto q-px-md"> <div class="q-mx-auto q-px-md">
<q-list> <q-list>
<q-item <q-item
v-for="(_, followedKey) in $store.state.theirProfile" v-for="(_, followedKey) in $store.state.following"
:key="followedKey" :key="followedKey"
v-ripple v-ripple
clickable clickable
@ -33,7 +33,7 @@
</q-item-section> </q-item-section>
<q-item-section>{{ <q-item-section>{{
$store.getters.handle(followedKey) $store.getters.displayName(followedKey)
}}</q-item-section> }}</q-item-section>
</q-item> </q-item>
</q-list> </q-list>

View File

@ -9,7 +9,9 @@
class="small-screen-only fixed-top-left q-ma-xs" class="small-screen-only fixed-top-left q-ma-xs"
/> />
<center><strong class="text-h6 q-ma-sm">Profile</strong></center> <div class="text-center">
<strong class="text-h6 q-ma-sm">Profile</strong>
</div>
<br /> <br />
<br /> <br />
@ -81,7 +83,6 @@
</template> </template>
<script> <script>
import 'md-gum-polyfill'
import helpersMixin from '../utils/mixin' import helpersMixin from '../utils/mixin'
import {pool} from '../global' import {pool} from '../global'
@ -100,7 +101,7 @@ export default {
computed: { computed: {
isFollowing() { isFollowing() {
return this.$route.params.pubkey in this.$store.state.theirProfile return this.$store.state.following.includes(this.$route.params.pubkey)
} }
}, },
@ -129,12 +130,12 @@ export default {
methods: { methods: {
unFollow() { unFollow() {
this.$store.dispatch('stopFollowing', this.$route.params.pubkey) this.$store.commit('unfollow', this.$route.params.pubkey)
this.$router.push('/') this.$router.push('/')
}, },
addPubFollow() { addPubFollow() {
this.$store.dispatch('startFollowing', this.$route.params.pubkey) this.$store.commit('follow', this.$route.params.pubkey)
} }
} }
} }

View File

@ -73,10 +73,10 @@
/> />
<q-input <q-input
disable disable
v-model="imagetemp" v-model="picture"
filled filled
type="text" type="text"
hint="Profile picture (imgur url)" hint="Picture URL"
maxlength="150" maxlength="150"
/> />
<q-btn <q-btn
@ -97,7 +97,7 @@
<div class="row"> <div class="row">
<div class="col-9"> <div class="col-9">
<q-input <q-input
v-model="relaya" v-model="addingRelay"
filled filled
type="textarea" type="textarea"
autogrow autogrow
@ -120,7 +120,7 @@
<div class="row"> <div class="row">
<div class="col-9"> <div class="col-9">
<q-select <q-select
v-model="relayr" v-model="removingRelay"
filled filled
:options="$store.state.myProfile.relays" :options="$store.state.myProfile.relays"
hint="Remove a relay" hint="Remove a relay"
@ -219,7 +219,7 @@
<q-card-actions align="right" class="text-primary"> <q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" v-close-popup /> <q-btn flat label="Cancel" v-close-popup />
<q-btn flat label="Yes, delete storage" @click="deletels" /> <q-btn flat label="Yes, delete storage" @click="hardReset" />
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
@ -229,44 +229,31 @@
<script> <script>
import helpersMixin from '../utils/mixin' import helpersMixin from '../utils/mixin'
import {copyToClipboard} from 'quasar' import {copyToClipboard} from 'quasar'
const bip39 = require('bip39')
export default { export default {
name: 'Settings', name: 'Settings',
mixins: [helpersMixin], mixins: [helpersMixin],
data() { data() {
const {imagetemp, handle, about} = this.$store.state.myProfile const {name, picture, about} = this.$store.state.me
return { return {
privatekey: null, privatekey: null,
publickey: null, publickey: null,
deleteAccDialog: false, deleteAccDialog: false,
privateKeyDialog: false, privateKeyDialog: false,
imagetemp, removingRelay: '',
handle, addingRelay: '',
about,
relayr: '',
relaya: '',
isPrivPwd: true, isPrivPwd: true,
isPwd: true, isPwd: true,
addPubKey: '' addPubKey: ''
} }
}, },
methods: { methods: {
addPubFollow() {
if (this.addPubKey.trim() !== this.$store.state.myProfile.pubkey) {
this.$store.dispatch('startFollowing', this.addPubKey.trim())
} else {
this.$q.notify({color: 'pink', message: 'You cant follow yourself!'})
}
this.addPubKey = ''
},
setProfile() { setProfile() {
this.$store.dispatch('saveMeta', { this.$store.dispatch('setMetadata', {
image: this.imagetemp, name: this.handle,
handle: this.handle, about: this.about,
about: this.about picture: this.picture
}) })
}, },
privateKey() { privateKey() {
@ -285,15 +272,15 @@ export default {
this.$q.notify({type: 'negative', message: 'FAILED'}) this.$q.notify({type: 'negative', message: 'FAILED'})
}) })
}, },
relayAdd() { addRelay() {
this.$store.dispatch('relayPush', this.relaya) this.$store.commit('addRelay', this.addingRelay)
this.relaya = '' this.addingRelay = ''
}, },
relayRem() { removeRelay() {
this.$store.dispatch('relayRemove', this.relayr) this.$store.commit('removeRelat', this.removingRelay)
this.relayr = '' this.removingRelay = ''
}, },
deletels() { hardReset() {
this.$q.localStorage.clear() this.$q.localStorage.clear()
window.location.reload() window.location.reload()
} }

View File

@ -4,29 +4,24 @@ const routes = [
component: () => import('layouts/MainLayout.vue'), component: () => import('layouts/MainLayout.vue'),
children: [ children: [
{path: '', component: () => import('pages/Home.vue'), name: 'home'}, {path: '', component: () => import('pages/Home.vue'), name: 'home'},
{
path: '/messages',
component: () => import('pages/Messages.vue'),
name: 'messages'
},
{path: '/chat/:pubkey', component: () => import('pages/Chat.vue')},
{
path: '/user/:pubkey',
component: () => import('pages/Profile.vue')
},
{
path: '/notifications',
component: () => import('pages/Notifications.vue')
},
{ {
path: '/settings', path: '/settings',
component: () => import('pages/Settings.vue'), component: () => import('pages/Settings.vue'),
name: 'settings' name: 'settings'
}, },
{ {
path: '/help', path: '/messages',
component: () => import('pages/Help.vue'), component: () => import('pages/Messages.vue'),
name: 'help' name: 'messages'
},
{path: '/messages/:pubkey', component: () => import('pages/Chat.vue')},
{
path: '/notifications',
component: () => import('pages/Notifications.vue')
},
{
path: '/:pubkey',
component: () => import('pages/Profile.vue')
} }
] ]
}, },

View File

@ -1,17 +1,30 @@
import {getEventHash} from 'nostr-tools' import {getEventHash} from 'nostr-tools'
import {encrypt, decrypt} from 'nostr-tools/nip04' import {encrypt, decrypt} from 'nostr-tools/nip04'
import {LocalStorage, Notify} from 'quasar' import {LocalStorage, Notify} from 'quasar'
import 'md-gum-polyfill'
import {pool} from '../global' import {pool} from '../global'
export function launch(store) { export function launch(store) {
pool.setPrivateKey(store.state.myProfile.privkey) if (!!store.state.keys.pub) {
store.commit('setKey') // passing no arguments will cause a new seed to be generated
}
store.state.myProfile.relays.forEach(relay => { // now we already have a key
pool.addRelay(relay) if (!!store.state.keys.priv) {
}) pool.setPrivateKey(store.state.keys.priv)
}
// add default relays
if (Object.keys(store.state.relays).length === 0) {
store.commit('addRelay', 'wss://freedom-relay.herokuapp.com/ws')
store.commit('addRelay', 'wss://relayer.fiatjaf.com')
store.commit('addRelay', 'wss://nostr-relay.freeberty.net')
}
// setup pool
for (let url in store.state.relays) {
pool.addRelay(url)
}
pool.onNotice((notice, relay) => { pool.onNotice((notice, relay) => {
Notify.create({ Notify.create({
message: `Relay ${relay.url} says: ${notice}`, message: `Relay ${relay.url} says: ${notice}`,
@ -28,153 +41,65 @@ export function restartHomeFeed(store) {
homeSubscription = homeSubscription.sub({ homeSubscription = homeSubscription.sub({
filter: [ filter: [
{ {
authors: Object.keys(store.state.theirProfile).length authors: store.state.following.length ? store.state.following : null
? Object.keys(store.state.theirProfile)
: null
}, },
{ {
author: store.state.myProfile.pubkey author: store.state.keys.pub
}, },
{ {
'#p': store.state.myProfile.pubkey '#p': store.state.keys.pub
} }
], ],
cb: (event, relay) => { cb: (event, relay) => {
switch (event.kind) { switch (event.kind) {
case 0: case 0:
store.commit('addKind0', event) try {
event.metadata = JSON.parse(event.content)
} catch (err) {}
break break
case 1: case 1:
for (let i = 0; i < store.state.kind1.length; i++) {
if (
(store.state.kind1[i].loading === true ||
store.state.kind1[i].retry === true) &&
store.state.kind1[i].id === event.id
) {
event.retry = false
event.loading = false
store.commit('replaceKind1', {index: i, event})
return
} else if (store.state.kind1[i].id === event.id) {
return
}
}
store.commit('addKind1', event)
break break
case 4: case 4:
// a direct encrypted message // a direct encrypted message
if ( if (
event.tags.find( event.tags.find(
tag => tag[0] === 'p' && tag[1] === store.state.myProfile.pubkey ([tag, value]) => tag === 'p' && value === store.state.keys.pub
) )
) { ) {
// it is addressed to us // it is addressed to us
let lsKey = `messages.${event.pubkey}`
var messages = LocalStorage.getItem(lsKey) || []
if (messages.find(({id}) => id === event.id)) {
// we already have this one, discard
return
}
// decrypt it // decrypt it
let [ciphertext, iv] = event.content.split('?iv=') let [ciphertext, iv] = event.content.split('?iv=')
let text = decrypt( event.plaintext = decrypt(
store.state.myProfile.privkey, store.state.keys.priv,
event.pubkey, event.pubkey,
ciphertext, ciphertext,
iv iv
) )
} else if (event.pubkey === store.state.keys.pub) {
// store it locally push
messages.push({
text,
from: event.pubkey,
id: event.id,
created_at: event.created_at,
tags: event.tags,
loading: false,
retry: false
})
LocalStorage.set(lsKey, messages)
// a hack to update the UI
store.commit('chatUpdated')
} else if (
event.pubkey === store.state.myProfile.pubkey &&
event.tags[0][1] in store.state.theirProfile
) {
// it is coming from us // it is coming from us
let p = event.tags.find(tag => tag[0] === 'p') let [_, target] = event.tags.find(([tag]) => tag === 'p')
let lsKey = `messages.${p[1]}` // decrypt it
var messagesS = LocalStorage.getItem(lsKey) let [ciphertext, iv] = event.content.split('?iv=')
event.plaintext = decrypt(
if (messagesS.length > 0) { store.state.keys.priv,
for (var i = 0; i < messagesS.length; i++) { target,
if ( ciphertext,
messagesS[i].id === event.id && iv
messagesS[i].loading === true )
) {
messagesS[i].loading = false
LocalStorage.set(lsKey, messagesS)
return
}
}
if (messagesS.find(({id}) => id === event.id)) {
// we already have this one, discard
return
}
// decrypt it
let [ciphertext, iv] = event.content.split('?iv=')
let text = decrypt(
store.state.myProfile.privkey,
p[1],
ciphertext,
iv
)
messagesS.push({
text,
from: event.pubkey,
id: event.id,
created_at: event.created_at,
tags: event.tags,
loading: false,
retry: false
})
LocalStorage.set(lsKey, messagesS)
}
} }
break break
} }
store.commit('addEvent', event)
} }
}) })
} }
export function relayPush(store, url) {
store.commit('relayPush', url)
pool.addRelay(url, {
read: true,
write: true
})
}
export async function relayRemove(store, url) {
store.commit('relaySplice', url)
pool.removeRelay(url)
}
export async function sendPost(store, {message, tags = [], kind = 1}) { export async function sendPost(store, {message, tags = [], kind = 1}) {
if (message.length === 0) return if (message.length === 0) return
let event = { let event = {
pubkey: store.state.myProfile.pubkey, pubkey: store.state.keys.pub,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
kind, kind,
tags, tags,
@ -184,164 +109,41 @@ export async function sendPost(store, {message, tags = [], kind = 1}) {
event.id = await getEventHash(event) event.id = await getEventHash(event)
pool.publish(event) pool.publish(event)
store.commit('addKind1', { store.commit('addEvent', event)
...event,
loading: true,
retry: false
})
} }
export function postAgain(store, event) { export async function setMetadata(store, metadata) {
for (let i = 0; i < store.state.kind1.length; i++) { store.commit('setProfile', metadata)
if (store.state.kind1[i].id === event.id) {
store.commit('replaceKind1', {
index: i,
event: {
...event,
loading: true,
retry: false
}
})
}
}
pool.publish(event)
}
export async function saveMeta(store, {image, handle, about}) {
store.commit('setProfile', {
...store.state.myProfile,
picture: image,
name: handle,
about
})
var event = { var event = {
pubkey: store.state.myProfile.pubkey, pubkey: store.state.keys.pub,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
kind: 0, kind: 0,
tags: [], tags: [],
content: JSON.stringify({ content: JSON.stringify(metadata)
name: store.state.myProfile.name,
about: store.state.myProfile.about,
picture: store.state.myProfile.picture
})
} }
event.id = await getEventHash(event) event.id = await getEventHash(event)
pool.publish(event) pool.publish(event)
} }
export function deletePost(store, postId) { export async function sendChatMessage(store, {pubkey, text, replyTo}) {
store.commit('deleteKind1', postId)
}
export function startFollowing(store, key) {
if (key in store.state.theirProfile) {
Notify.create({
message: 'Already following',
color: 'pink'
})
return
}
if (!key.match(/^[0-9a-fA-F]{64}$/)) {
Notify.create({
message:
'Invalid public key. Must be 32 bytes hex-encoded (64 characters).',
color: 'pink'
})
return
}
LocalStorage.set(`messages.${key}`, [])
store.commit('startFollowing', key)
store.dispatch('restartHomeFeed')
}
export async function stopFollowing(store, key) {
if (!(key in store.state.theirProfile)) {
Notify.create({
message: 'No such user',
color: 'pink'
})
return
}
store.commit('stopFollowing', key)
store.dispatch('restartHomeFeed')
}
export function finalGenerate(store, {keystoreoption, publickey, privatekey}) {
var profile = {
pubkey: publickey,
privkey: privatekey,
relays: [
'wss://nostr-relay.herokuapp.com/ws',
'wss://nostr-relay.bigsun.xyz/ws',
'wss://freedom-relay.herokuapp.com/ws'
// 'wss://relay.nostr.org',
// 'wss://nodestr-relay.dolu.dev/ws'
],
avatar: null,
handle: null,
about: null
}
if (keystoreoption === 'external') {
profile.privkey = null
}
store.commit('setProfile', profile)
LocalStorage.set('theirProfile', {})
LocalStorage.set('kind1', [])
store.dispatch('launch')
}
export async function sendChatMessage(store, {pubkey, text}) {
if (text.length === 0) return if (text.length === 0) return
let [ciphertext, iv] = encrypt(store.state.myProfile.privkey, pubkey, text) let [ciphertext, iv] = encrypt(store.state.keys.priv, pubkey, text)
// make event // make event
let event = { let event = {
pubkey: store.state.myProfile.pubkey, pubkey: store.state.keys.pub,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
kind: 4, kind: 4,
tags: [['p', pubkey]], tags: [['p', pubkey]],
content: ciphertext + '?iv=' + iv content: ciphertext + '?iv=' + iv
} }
if (replyTo) {
let lsKey = `messages.${pubkey}` event.tags.push(['e', replyTo])
var messages = LocalStorage.getItem(lsKey) || []
if (messages.length > 0) {
event.tags.push(['e', messages[messages.length - 1].id])
} }
event.id = await getEventHash(event) event.id = await getEventHash(event)
let message = {
text,
from: store.state.myProfile.pubkey,
id: event.id,
created_at: event.created_at,
tags: event.tags,
loading: true,
failed: false
}
messages.push(message)
LocalStorage.set(lsKey, messages)
pool.publish(event) pool.publish(event)
} }
export function deleteChatMessage(store, {pubkey, id}) {
let lsKey = `messages.${pubkey}`
var messages = LocalStorage.getItem(lsKey) || []
let index = messages.findIndex(message => message.id === id)
if (index === -1) return
messages.splice(index, 1)
LocalStorage.set(lsKey, messages)
}

View File

@ -1,29 +1,21 @@
import Identicon from 'identicon.js' import Identicon from 'identicon.js'
export function disabled(state) { export function disabled(state) {
return !state.myProfile return !state.keys.pub
} }
export function handle(state, pubkey) { export function displayName(state) {
return pubkey => { return pubkey => {
let profile = state.theirProfile[pubkey] let {metadata = {}} = state.events.kind0[pubkey]
if (profile && profile.name) return profile.name if (metadata.name) return metadata.name
return pubkey.slice(0, 3) + '...' + pubkey.slice(-4)
let kind0 = state.kind0[pubkey]
if (kind0 && kind0.name) return profile.name
return pubkey.slice(0, 20) + '...'
} }
} }
export function avatar(state) { export function avatar(state) {
return pubkey => { return pubkey => {
let profile = state.theirProfile[pubkey] let {metadata = {}} = state.events.kind0[pubkey]
if (profile && profile.picture) return profile.picture if (metadata.picture) return metadata.picture
let kind0 = state.kind0[pubkey]
if (kind0 && kind0.picture) return profile.picture
let data = new Identicon(pubkey, 40).toString() let data = new Identicon(pubkey, 40).toString()
return 'data:image/png;base64,' + data return 'data:image/png;base64,' + data
} }

View File

@ -1,61 +1,71 @@
export function setProfile(state, profile) { import {LocalStorage} from 'quasar'
state.myProfile = profile import {getPublicKey} from 'nostr-tools'
import bip32 from 'bip32'
import * as bip39 from 'bip39'
export function setKey(state, {seed, priv, pub} = {}) {
if (!seed && !priv && !pub) {
// generate
let randomBytes = crypto.randomBytes(16)
let mnemonic = bip39.entropyToMnemonic(randomBytes.toString('hex'))
seed = bip39.mnemonicToSeedSync(mnemonic)
}
if (seed) {
let root = bip32.fromSeed(seed)
priv = root.privateKey.toString('hex')
}
if (priv) {
pub = getPublicKey(priv)
}
state.keys = {seed, priv, pub}
} }
export function relayPush(state, url) { export function setMetadata(state, {name, picture, about}) {
state.myProfile.relays.push(url) state.me = {name, picture, about}
} }
export function relaySplice(state, url) { export function addRelay(state, url) {
let index = state.myProfile.relays.indexOf(url) state.relays[url] = {
if (index === -1) return read: true,
state.myProfile.relays.splice(index, 1) write: true
}
export function startFollowing(state, key) {
// use metadata from kind0 or leave everything blank
state.theirProfile = {
[key]: state.kind0[key] || {name: null, about: null, picture: null},
...state.theirProfile
} }
} }
export function stopFollowing(state, key) { export function removeRelay(state, url) {
delete state.theirProfile[key] delete state.relays[url]
} }
export function addKind1(state, event) { export function follow(state, key) {
state.kind1.unshift(event) if (state.following.includes(key)) {
}
export function replaceKind1(state, {index, event}) {
state.kind1 = [
...state.kind1.slice(0, index),
event,
...state.kind1.slice(index + 1)
]
}
export function deleteKind1(state, id) {
console.log(state.kind1)
console.log(id)
let index = state.kind1.findIndex(event => event.id === id)
console.log(index)
if (index !== -1) state.kind1.splice(index, 1)
}
export function addKind0(state, event) {
// increment theirProfile with this or store it temporarily
try {
let {name, about, picture} = JSON.parse(event.content)
if (event.pubkey in state.theirProfile) {
state.theirProfile[event.pubkey] = {name, about, picture}
return
}
state.kind0[event.pubkey] = {name, about, picture}
} catch (err) {
return return
} }
state.following.push(key)
}
export function unfollow(state, key) {
delete state.following[key]
}
export function addEvent(state, event) {
switch (event.kind) {
case 0:
state.events.kind0[event.pubkey] = event
break
case 1:
if (state.events.kind1.find(e => e.id === event.id)) return
state.events.kind1.push(event)
break
case 4:
let peerTag = event.tags.find(([tag]) => tag === 'p')
if (!peerTag) return
let peer = event.pubkey === state.key.pub ? peerTag[1] : event.pubkey
if (state.events.kind4[peer].find(e => e.id === event.id)) return
state.events.kind4[peer].push(event)
break
}
} }
export function chatUpdated(state) { export function chatUpdated(state) {

View File

@ -2,11 +2,15 @@ import {LocalStorage} from 'quasar'
export default function () { export default function () {
return { return {
myProfile: LocalStorage.getItem('myProfile'), me: LocalStorage.getItem('me') || {}, // { name, picture, about, ... }
theirProfile: LocalStorage.getItem('theirProfile') || {}, keys: LocalStorage.getItem('keys') || {}, // { seed, priv, pub }
relays: LocalStorage.getItem('relays') || {}, // { [url]: {} }
kind0: {}, // temporary, will be merged with theirProfile or erased at the end following: LocalStorage.getItem('following') || [], // [ pubkeys... ]
kind1: LocalStorage.getItem('kind1') || [], events: {
kind0: LocalStorage.getItem('events.kind0') || {}, // { [pubkey]: event }
kind1: LocalStorage.getItem('events.kind1') || [], // [ events... ]
kind4: LocalStorage.getItem('events.kind4') || {} // { [pubkey]: [events...] }
},
chatUpdated: 1 // hack chatUpdated: 1 // hack
} }

View File

@ -4,20 +4,19 @@ export default function (store) {
store.subscribe(({type, payload}, state) => { store.subscribe(({type, payload}, state) => {
switch (type) { switch (type) {
case 'setProfile': case 'setProfile':
case 'relayPush': LocalStorage.set('me', state.me)
case 'relaySplice': case 'addRelay':
console.log('storing', state.myProfile) case 'removeRelay':
LocalStorage.set('myProfile', state.myProfile) LocalStorage.set('relays', state.relays)
break break
case 'startFollowing': case 'follow':
case 'stopFollowing': case 'unfollow':
case 'addKind0': LocalStorage.set('following', state.following)
LocalStorage.set('theirProfile', state.theirProfile) case 'addEvent':
break LocalStorage.set(
case 'addKind1': `events.${payload.kind}`,
case 'replaceKind1': state.events[`kind${payload.kind}`]
case 'deleteKind1': )
LocalStorage.set('kind1', state.kind1)
break break
} }
}) })

View File

@ -11,7 +11,7 @@ export default {
}, },
methods: { methods: {
toProfile(pubkey) { toProfile(pubkey) {
this.$router.push('/user/' + pubkey) this.$router.push('/' + pubkey)
}, },
niceDate(value) { niceDate(value) {

View File

@ -992,6 +992,11 @@
resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.3.tgz#0300943770e04231041a51bd39f0439b5c7ab4f0" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.3.tgz#0300943770e04231041a51bd39f0439b5c7ab4f0"
integrity sha512-nkalE/f1RvRGChwBnEIoBfSEYOXnCRdleKuv6+lePbMDrMZXeDQnqak5XDOeBgrPPyPfAdcCu/B5z+v3VhplGg== integrity sha512-nkalE/f1RvRGChwBnEIoBfSEYOXnCRdleKuv6+lePbMDrMZXeDQnqak5XDOeBgrPPyPfAdcCu/B5z+v3VhplGg==
"@noble/secp256k1@^1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.3.0.tgz#426880cf0355b24d81c129af1ec31dfa6eee8b9c"
integrity sha512-wuFthUc6Ul4xflhY5u1+p1bDILPzVEisekxt5M+iLg4R+gG6+k2jnRR19sC9fMtzlsN5sKloBwprziDS0XlmyQ==
"@nodelib/fs.scandir@2.1.5": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
@ -1270,6 +1275,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.12.tgz#ac7fb693ac587ee182c3780c26eb65546a1a3c10" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.12.tgz#ac7fb693ac587ee182c3780c26eb65546a1a3c10"
integrity sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw== integrity sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw==
"@types/node@10.12.18":
version "10.12.18"
resolved "https://registry.yarnpkg.com/@types/node/-/node-10.12.18.tgz#1d3ca764718915584fcd9f6344621b7672665c67"
integrity sha512-fh+pAqt4xRzPfqA6eh3Z2y6fyZavRIumvjhaCL753+TVkGKGhpPeyrJG2JftD0T9q4GF00KjefsQ+PQNDdWQaQ==
"@types/node@11.11.6": "@types/node@11.11.6":
version "11.11.6" version "11.11.6"
resolved "https://registry.yarnpkg.com/@types/node/-/node-11.11.6.tgz#df929d1bb2eee5afdda598a41930fe50b43eaa6a" resolved "https://registry.yarnpkg.com/@types/node/-/node-11.11.6.tgz#df929d1bb2eee5afdda598a41930fe50b43eaa6a"
@ -1850,6 +1860,13 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base-x@^3.0.2:
version "3.0.9"
resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.9.tgz#6349aaabb58526332de9f60995e548a53fe21320"
integrity sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==
dependencies:
safe-buffer "^5.0.1"
base64-js@^1.3.1: base64-js@^1.3.1:
version "1.5.1" version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@ -1870,6 +1887,18 @@ binary-extensions@^2.0.0:
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
bip32@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/bip32/-/bip32-3.0.1.tgz#1d1121469cce6e910e0ec3a5a1990dd62687e2a3"
integrity sha512-Uhpp9aEx3iyiO7CpbNGFxv9WcMIVdGoHG04doQ5Ln0u60uwDah7jUSc3QMV/fSZGm/Oo01/OeAmYevXV+Gz5jQ==
dependencies:
"@types/node" "10.12.18"
bs58check "^2.1.1"
create-hash "^1.2.0"
create-hmac "^1.1.7"
typeforce "^1.11.5"
wif "^2.0.6"
bip39@^3.0.4: bip39@^3.0.4:
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/bip39/-/bip39-3.0.4.tgz#5b11fed966840b5e1b8539f0f54ab6392969b2a0" resolved "https://registry.yarnpkg.com/bip39/-/bip39-3.0.4.tgz#5b11fed966840b5e1b8539f0f54ab6392969b2a0"
@ -1948,6 +1977,22 @@ browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4
node-releases "^2.0.1" node-releases "^2.0.1"
picocolors "^1.0.0" picocolors "^1.0.0"
bs58@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/bs58/-/bs58-4.0.1.tgz#be161e76c354f6f788ae4071f63f34e8c4f0a42a"
integrity sha1-vhYedsNU9veIrkBx9j806MTwpCo=
dependencies:
base-x "^3.0.2"
bs58check@<3.0.0, bs58check@^2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/bs58check/-/bs58check-2.1.2.tgz#53b018291228d82a5aa08e7d796fdafda54aebfc"
integrity sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==
dependencies:
bs58 "^4.0.0"
create-hash "^1.1.0"
safe-buffer "^5.1.2"
buffer-crc32@^0.2.1, buffer-crc32@^0.2.13: buffer-crc32@^0.2.1, buffer-crc32@^0.2.13:
version "0.2.13" version "0.2.13"
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
@ -2344,7 +2389,7 @@ crc32-stream@^4.0.2:
crc-32 "^1.2.0" crc-32 "^1.2.0"
readable-stream "^3.4.0" readable-stream "^3.4.0"
create-hash@^1.1.0, create-hash@^1.1.2: create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196"
integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==
@ -2355,7 +2400,7 @@ create-hash@^1.1.0, create-hash@^1.1.2:
ripemd160 "^2.0.1" ripemd160 "^2.0.1"
sha.js "^2.4.0" sha.js "^2.4.0"
create-hmac@^1.1.4: create-hmac@^1.1.4, create-hmac@^1.1.7:
version "1.1.7" version "1.1.7"
resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff"
integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==
@ -4168,11 +4213,6 @@ make-dir@^3.0.2, make-dir@^3.1.0:
dependencies: dependencies:
semver "^6.0.0" semver "^6.0.0"
md-gum-polyfill@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/md-gum-polyfill/-/md-gum-polyfill-1.0.0.tgz#829a512d25ed0318c0c49a961048b505f670a2db"
integrity sha1-gppRLSXtAxjAxJqWEEi1BfZwots=
md5.js@^1.3.4: md5.js@^1.3.4:
version "1.3.5" version "1.3.5"
resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f"
@ -4373,11 +4413,6 @@ no-case@^3.0.4:
lower-case "^2.0.2" lower-case "^2.0.2"
tslib "^2.0.3" tslib "^2.0.3"
noble-secp256k1@^1.1.1:
version "1.2.14"
resolved "https://registry.yarnpkg.com/noble-secp256k1/-/noble-secp256k1-1.2.14.tgz#39429c941d51211ca40161569cee03e61d72599e"
integrity sha512-GSCXyoZBUaaPwVWdYncMEmzlSUjF9J/YeEHpklYJCyg8wPuJP3NzDx0BkiwArzINkdX2HJHvUJhL6vVWPOQQcg==
node-forge@^0.10.0: node-forge@^0.10.0:
version "0.10.0" version "0.10.0"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3"
@ -4415,14 +4450,14 @@ normalize-url@^6.0.1:
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a"
integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==
nostr-tools@^0.5.0: nostr-tools@^0.6.0:
version "0.5.0" version "0.6.0"
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-0.5.0.tgz#cb641ff21d035169590cc7185eca6aed6a9a101c" resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-0.6.0.tgz#03f6ac3bd72ed7ead9b2472b655228ebc011bb92"
integrity sha512-JxLOP8psDx3Ye08ar8pbY7u08zJUIo9sS3CqpPqfvgV86Blv9oImKP/GfODP0H3MbfOKk4zdYQt6+qZie0k8uA== integrity sha512-XBi+tuRQo9prL8n1Wxu3/O1NYGc4tQaIExaLfebj3eg0um8xDnKzgpmbRL+dTXqaZmWKnTtTAbZvea2uluXIPw==
dependencies: dependencies:
"@noble/secp256k1" "^1.3.0"
buffer "^6.0.3" buffer "^6.0.3"
dns-packet "^5.2.4" dns-packet "^5.2.4"
noble-secp256k1 "^1.1.1"
websocket-polyfill "^0.0.3" websocket-polyfill "^0.0.3"
npm-run-path@^4.0.1: npm-run-path@^4.0.1:
@ -5920,6 +5955,11 @@ typedarray-to-buffer@^3.1.5:
dependencies: dependencies:
is-typedarray "^1.0.0" is-typedarray "^1.0.0"
typeforce@^1.11.5:
version "1.18.0"
resolved "https://registry.yarnpkg.com/typeforce/-/typeforce-1.18.0.tgz#d7416a2c5845e085034d70fcc5b6cc4a90edbfdc"
integrity sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==
typescript@4.4.2: typescript@4.4.2:
version "4.4.2" version "4.4.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.4.2.tgz#6d618640d430e3569a1dfb44f7d7e600ced3ee86" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.4.2.tgz#6d618640d430e3569a1dfb44f7d7e600ced3ee86"
@ -6268,6 +6308,13 @@ which@^2.0.1:
dependencies: dependencies:
isexe "^2.0.0" isexe "^2.0.0"
wif@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/wif/-/wif-2.0.6.tgz#08d3f52056c66679299726fade0d432ae74b4704"
integrity sha1-CNP1IFbGZnkplyb63g1DKudLRwQ=
dependencies:
bs58check "<3.0.0"
wildcard@^2.0.0: wildcard@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec" resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec"