* Add profile settings

* Add NIP05 support
This commit is contained in:
styppo 2023-01-13 05:04:08 +00:00
parent 18ea594080
commit efc9af8bc1
No known key found for this signature in database
GPG Key ID: 3AAA685C50724C28
13 changed files with 333 additions and 109 deletions

View File

@ -79,13 +79,13 @@ md.use(subscript)
trimmed.endsWith('.jpg') || trimmed.endsWith('.jpg') ||
trimmed.endsWith('.svg') trimmed.endsWith('.svg')
) { ) {
return `<img src="${src}" crossorigin async loading='lazy' style="max-width: 90%; max-height: 50vh;">` return `<img src="${src}" 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') ||
trimmed.endsWith('.ogg') trimmed.endsWith('.ogg')
) { ) {
return `<video src="${src}" controls crossorigin async style="max-width: 90%; max-height: 50vh;"></video>` return `<video src="${src}" controls style="max-width: 90%; max-height: 50vh;"></video>`
} }
} }

View File

@ -0,0 +1,182 @@
<template>
<!-- <div class="profile-card">-->
<!-- <UserAvatar :pubkey="pubkey" class="profile-card-avatar" />-->
<!-- <div class="profile-card-content">-->
<!-- <p><UserName :pubkey="pubkey" two-line header show-verified /></p>-->
<!-- <p class="about">{{ about }}</p>-->
<!-- </div>-->
<!-- </div>-->
<q-form class="profile-settings">
<h3>Profile</h3>
<div class="input">
<q-input v-model="name" label="Name" maxlength="64" autogrow dense />
</div>
<div class="input">
<q-input v-model="about" label="About" maxlength="150" autogrow dense />
</div>
<div class="input">
<q-input v-model="picture" label="Picture URL" autogrow dense />
<img :src="picture" class="picture-preview" loading="lazy" />
</div>
<div class="input">
<q-input v-model="nip05" label="NIP05 Identifier" autogrow dense />
</div>
<div class="buttons">
<q-btn type="submit" :disable="!changed" label="Save" flat rounded color="primary" />
<q-btn label="Reset" :disable="!changed" flat rounded @click="updateData" />
<!-- <button type="submit" class="btn btn-sm btn-primary">Save</button>-->
<!-- <button class="btn btn-sm" @click="updateData">Reset</button>-->
</div>
</q-form>
</template>
<script>
import {useNostrStore} from 'src/nostr/NostrStore'
import {useAppStore} from 'stores/App'
// import UserAvatar from 'components/User/UserAvatar.vue'
// import UserName from 'components/User/UserName.vue'
export default {
name: 'ProfileSettings',
components: {},
setup() {
return {
app: useAppStore(),
nostr: useNostrStore(),
}
},
data() {
return {
name: '',
about: '',
picture: '',
nip05: '',
}
},
computed: {
pubkey() {
return this.app.myPubkey
},
profile() {
return this.nostr.getProfile(this.pubkey)
},
changed() {
return this.name !== (this.profile?.name || '')
|| this.about !== (this.profile?.about || '')
|| this.picture !== (this.profile?.picture || '')
|| this.nip05 !== (this.profile?.nip05?.url || '')
},
},
methods: {
updateData() {
this.name = (this.profile?.name || '')
this.about = (this.profile?.about || '')
this.picture = (this.profile?.picture || '')
this.nip05 = (this.profile?.nip05?.url || '')
}
},
watch: {
profile() {
this.updateData()
}
},
mounted() {
this.updateData()
}
}
</script>
<style lang="scss" scoped>
@import "assets/theme/colors.scss";
//.profile-card {
// display: flex;
// border-radius: 1rem;
// background-color: rgba($color: $color-dark-gray, $alpha: 0.2);
// padding: .5rem 1rem;
// margin-bottom: 1rem;
// &-avatar {
// height: 128px;
// width: 128px;
// margin-right: 1rem;
// }
//}
.profile-settings {
background-color: rgba($color: $color-dark-gray, $alpha: 0.1);
border-radius: 1rem;
h3 {
margin: 0;
padding: 1rem;
font-size: 1.4rem;
border-bottom: $border-dark;
}
.input {
position: relative;
//padding: .5rem .5rem .5rem 1rem;
transition: 200ms ease;
//border-bottom: $border-dark;
//input {
// color: #fff;
// font-weight: 500;
// width: 100%;
// outline: none;
// background-color: transparent;
// border: 0;
// padding: 0;
//}
&:hover, &.focused {
background-color: rgba($color: $color-light-gray, $alpha: 0.2);
}
&:first-child {
border-radius: 1rem 1rem 0 0;
}
.picture-preview {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
}
}
.buttons {
display: flex;
flex-direction: row-reverse;
background-color: rgba($color: $color-dark-gray, $alpha: 0.15);
border-radius: 0 0 1rem 1rem;
padding: .35rem;
button {
letter-spacing: 1px;
font-weight: 600;
}
button + button {
margin-right: .5rem;
}
}
}
</style>
<style lang="scss">
@import "assets/theme/colors.scss";
.profile-settings .input {
.q-field__label {
color: $color-light-gray;
margin: 0 1rem;
}
textarea {
color: #fff;
padding: 0 1rem;
font-weight: 500;
}
.q-field__control:before {
border-bottom: $border-dark;
}
.q-field__control-container {
padding-top: 20px !important;
padding-bottom: 6px;
}
.q-field--dense .q-field__label {
top: 14px;
}
}
</style>

View File

@ -1,5 +1,6 @@
<template> <template>
<div class="relay-settings"> <div class="relay-settings">
<h3>Relays</h3>
<div v-for="relay in settings.relays" :key="relay" class="relay"> <div v-for="relay in settings.relays" :key="relay" class="relay">
<span class="relay-url">{{ relay }}</span> <span class="relay-url">{{ relay }}</span>
<!-- <q-icon v-if="isConnected(relay)" icon="fiber_manual_record" size="sm" class="connected" />--> <!-- <q-icon v-if="isConnected(relay)" icon="fiber_manual_record" size="sm" class="connected" />-->
@ -85,6 +86,12 @@ export default {
.relay-settings { .relay-settings {
background-color: rgba($color: $color-dark-gray, $alpha: 0.1); background-color: rgba($color: $color-dark-gray, $alpha: 0.1);
border-radius: 1rem; border-radius: 1rem;
h3 {
margin: 0;
padding: 1rem;
font-size: 1.4rem;
border-bottom: $border-dark;
}
.relay { .relay {
display: flex; display: flex;
align-items: center; align-items: center;
@ -122,6 +129,7 @@ export default {
outline: none; outline: none;
background-color: transparent; background-color: transparent;
border: 0; border: 0;
padding: 0;
} }
&:hover, &.focused { &:hover, &.focused {
background-color: rgba($color: $color-light-gray, $alpha: 0.2); background-color: rgba($color: $color-light-gray, $alpha: 0.2);

View File

@ -1,44 +1,14 @@
<template> <template>
<q-dialog v-model="NIP05Dialog"> <span v-if="verified" class="nip05-badge">
<q-card class="flex column no-wrap" style="max-height: 90%"> <q-icon name="verified" :size="size" color="primary">
<div class="flex row justify-end"> <q-tooltip>NIP05 verified</q-tooltip>
<q-btn icon="close" flat dense v-close-popup/> </q-icon>
</div> <span class="nip05-badge-text">{{ nip05 }}</span>
<div class="overflow-auto"> </span>
<q-card-section>
<div class="text-subtitle1 flex row overflow-auto items-end q-gutter-sm">
NIP05 identifier
<a :href="NIP05Link" target="_">{{ NIP05Link }}</a>
</div>
<pre v-if="NIP05Loaded">{{ NIP05Formatted }}</pre>
<q-inner-loading :showing="!NIP05Loaded">
<q-spinner-orbit color="accent" size="2rem" />
</q-inner-loading>
<div>
Learn how to get NIP05 verified&nbsp;<a href="https://gist.github.com/metasikander/609a538e6a03b2f67e5c8de625baed3e" target='_'>here</a>
</div>
</q-card-section>
</div>
</q-card>
</q-dialog>
<q-btn
v-if="$store.getters.NIP05Id(pubkey)"
icon="verified"
flat
dense
:size="size"
class="no-padding"
clickable
@click.stop="openNIP05"
>
<q-tooltip>
NIP05 verified
</q-tooltip>
</q-btn>
</template> </template>
<script> <script>
import fetch from 'cross-fetch' import {useNostrStore} from 'src/nostr/NostrStore'
export default { export default {
name: 'Nip05Badge', name: 'Nip05Badge',
@ -49,51 +19,47 @@ export default {
}, },
size: { size: {
type: String, type: String,
default: 'xs' default: '14px'
}
},
setup() {
return {
nostr: useNostrStore(),
} }
}, },
data() { data() {
return { return {
NIP05Dialog: false, verified: false,
NIP05Data: {},
} }
}, },
computed: { computed: {
NIP05Link() { profile() {
// let [name, domain] = this.$store.getters return this.nostr.getProfile(this.pubkey)
// .NIP05Id(this.pubkey)
// .split('@')
// if (!domain) {
// domain = name
// name = '_'
// }
// return `https://${domain}/.well-known/nostr.json?name=${name}`
return 'TODO'
}, },
NIP05Formatted() { nip05() {
return this.json(this.NIP05Data) if (!this.profile?.nip05.url) return
}, return this.profile.nip05.url
NIP05Loaded() { .split('@')
if (Object.keys(this.NIP05Data).length) return true .filter(part => part !== '_' && part !== this.profile.name)
return false .join('@')
} }
}, },
watch: {
methods: { async profile() {
openNIP05() { this.verified = await this.profile?.isNip05Verified()
this.loadNIP05Data() }
this.NIP05Dialog = !this.NIP05Dialog },
}, async mounted() {
this.verified = await this.profile?.isNip05Verified()
async loadNIP05Data() {
try {
this.NIP05Data = await (await fetch(this.NIP05Link)).json()
} catch (e) {
console.warn(`Failed to fetch NIP05 identifier ${this.NIP05Link}`, e)
}
},
} }
} }
</script> </script>
<style lang="scss" scoped>
.nip05-badge {
font-size: 12px;
&-text {
margin-left: 2px;
}
}
</style>

View File

@ -9,7 +9,6 @@
:src="avatarUrl" :src="avatarUrl"
ref="image" ref="image"
loading="lazy" loading="lazy"
crossorigin
@error.once="onFetchFailed" @error.once="onFetchFailed"
/> />
<Identicon <Identicon

View File

@ -4,7 +4,9 @@
:class="{'two-line': twoLine, clickable, header}" :class="{'two-line': twoLine, clickable, header}"
> >
<a @click="clickable && goToProfile(pubkey)"> <a @click="clickable && goToProfile(pubkey)">
<span v-if="profile?.name" class="name">{{ profile.name }}</span> <span v-if="profile?.name" class="name">
{{ profile.name }}
</span>
<!-- <q-icon v-if="showFollowing && isFollow" name="visibility" color="secondary">--> <!-- <q-icon v-if="showFollowing && isFollow" name="visibility" color="secondary">-->
<!-- <q-tooltip>--> <!-- <q-tooltip>-->
<!-- following--> <!-- following-->
@ -13,12 +15,7 @@
<Bech32Label v-if="twoLine || !profile?.name" prefix="npub" :hex="pubkey" class="pubkey" /> <Bech32Label v-if="twoLine || !profile?.name" prefix="npub" :hex="pubkey" class="pubkey" />
</a> </a>
<span v-if="showVerified && profile?.nip05?.verified"> <Nip05Badge :pubkey="pubkey" />
<Nip05Badge :pubkey="pubkey" />
<span style="opacity: .9; font-size: 90%; font-weight: 300; line-height: 90%">
{{ niceNip05 }}
</span>
</span>
</span> </span>
</template> </template>
@ -57,7 +54,7 @@ export default {
}, },
showVerified: { showVerified: {
type: Boolean, type: Boolean,
default: false default: true
}, },
}, },
setup() { setup() {
@ -69,17 +66,11 @@ export default {
profile() { profile() {
return this.nostr.getProfile(this.pubkey) return this.nostr.getProfile(this.pubkey)
}, },
niceNip05() {
return this.profile.nip05.url
.split('@')
.filter(part => part !== '_' && part !== this.profile.name)
.join('@')
},
isFollow() { isFollow() {
// FIXME // FIXME
return false return false
}, },
} },
} }
</script> </script>

View File

@ -73,7 +73,7 @@ export default class FetchQueue extends Observable {
delete this.queue[id] delete this.queue[id]
filteredIds.splice(filteredIds.indexOf(id), 1) filteredIds.splice(filteredIds.indexOf(id), 1)
console.log(`Fetched ${this.subId} ${id}, ${filteredIds.length} remaining`) // console.log(`Fetched ${this.subId} ${id}, ${filteredIds.length} remaining`)
this.emit('event', event, relay) this.emit('event', event, relay)

View File

@ -68,6 +68,8 @@ export const useNostrStore = defineStore('nostr', {
this.contactQueue = contactQueue(this.client, 'queue') this.contactQueue = contactQueue(this.client, 'queue')
this.contactQueue.on('event', this.addEvent.bind(this)) this.contactQueue.on('event', this.addEvent.bind(this))
this.getProfiles(Object.keys(settings.accounts))
}, },
addEvent(event, relay = null) { addEvent(event, relay = null) {
@ -132,10 +134,18 @@ export const useNostrStore = defineStore('nostr', {
return profile return profile
}, },
getProfiles(pubkeys) {
const profiles = []
for (const pubkey of pubkeys) {
profiles.push(this.getProfile(pubkey))
}
return profiles
},
getNote(id) { getNote(id) {
const notes = useNoteStore() const notes = useNoteStore()
let note = notes.get(id) let note = notes.get(id)
if (!note) this.noteQueue.add(id) if (!note && !this.hasEvent(id)) this.noteQueue.add(id)
return note return note
}, },
@ -152,8 +162,7 @@ export const useNostrStore = defineStore('nostr', {
return notes.getNotesByAuthor(pubkey, order) return notes.getNotesByAuthor(pubkey, order)
}, },
fetchNotesByAuthor(pubkey, opts = {}) { fetchNotesByAuthor(pubkey, limit = 100) {
const limit = opts.limit || 100
return this.fetchMultiple( return this.fetchMultiple(
{ {
kinds: [EventKind.NOTE], kinds: [EventKind.NOTE],
@ -320,11 +329,12 @@ export const useNostrStore = defineStore('nostr', {
const sub = this.client.subscribe(filtersWithLimit, opts.subId || null) const sub = this.client.subscribe(filtersWithLimit, opts.subId || null)
const timer = setTimeout(() => { const timer = setTimeout(() => {
console.log(`[TIMEOUT] ${sub.subId}, intialFetchComplete=${initialFetchComplete}`)
if (!initialFetchComplete) { if (!initialFetchComplete) {
initialFetchComplete = true initialFetchComplete = true
if (initialFetchCompleteCallback) initialFetchCompleteCallback() if (initialFetchCompleteCallback) initialFetchCompleteCallback()
} }
}, opts.timeout || 5000) }, opts.timeout || 3000)
sub.on('event', (event, relay) => { sub.on('event', (event, relay) => {
const known = this.hasEvent(event.id) const known = this.hasEvent(event.id)
const obj = this.addEvent(event, relay) const obj = this.addEvent(event, relay)
@ -332,13 +342,18 @@ export const useNostrStore = defineStore('nostr', {
if (eventCallback) eventCallback(obj, relay) if (eventCallback) eventCallback(obj, relay)
if (++numEventsSeen >= initialFetchSize && !initialFetchComplete) { // if (++numEventsSeen >= initialFetchSize && !initialFetchComplete) {
initialFetchComplete = true // console.log(`[EVENTS_SEEN] ${sub.subId} ${numEventsSeen}, intialFetchComplete=${initialFetchComplete}`)
clearTimeout(timer) // initialFetchComplete = true
if (initialFetchCompleteCallback) initialFetchCompleteCallback() // clearTimeout(timer)
} // if (initialFetchCompleteCallback) initialFetchCompleteCallback()
// }
})
sub.on('eose', (relay, subId) => {
console.log(`[EOSE] ${subId} ${relay} ${this.client.connectedRelays().length}`)
}) })
sub.on('complete', () => { sub.on('complete', () => {
console.log(`[COMPLETE] ${sub.subId}, intialFetchComplete=${initialFetchComplete}`)
if (!initialFetchComplete) { if (!initialFetchComplete) {
initialFetchComplete = true initialFetchComplete = true
clearTimeout(timer) clearTimeout(timer)

View File

@ -59,10 +59,12 @@ class MultiSubscription extends Observable {
} }
export default class ReplayPool extends Observable { export default class ReplayPool extends Observable {
constructor(urls) { constructor(urls, minRelays = 5) {
super() super()
this.relays = {} this.relays = {}
this.minRelays = Math.min(minRelays, urls.length)
this.subs = {} this.subs = {}
this.nextSubId = 0 this.nextSubId = 0
@ -106,9 +108,15 @@ export default class ReplayPool extends Observable {
sub.on('close', this.unsubscribe.bind(this, subId)) sub.on('close', this.unsubscribe.bind(this, subId))
this.subs[subId] = {sub, filters, closeAfter} this.subs[subId] = {sub, filters, closeAfter}
for (const relay of this.connectedRelays()) { const connectedRelays = this.connectedRelays()
sub.add(relay.subscribe(filters, subId, closeAfter)) if (connectedRelays.length >= this.minRelays) {
for (const relay of connectedRelays) {
sub.add(relay.subscribe(filters, subId, closeAfter))
}
} else {
this.subs[subId].pending = true
} }
return sub return sub
} }
@ -135,12 +143,28 @@ export default class ReplayPool extends Observable {
return Object.values(this.relays).filter(relay => relay.isConnected()) return Object.values(this.relays).filter(relay => relay.isConnected())
} }
numConnectedRelays() {
return this.connectedRelays().length
}
onOpen(relay) { onOpen(relay) {
console.log(`Connected to ${relay}`, relay) console.log(`Connected to ${relay}`, relay)
for (const subId of Object.keys(this.subs)) { for (const subId of Object.keys(this.subs)) {
const sub = this.subs[subId] const sub = this.subs[subId]
sub.sub.add(relay.subscribe(sub.filters, subId, sub.closeAfter)) const connectedRelays = this.connectedRelays()
if (connectedRelays.length >= this.minRelays) {
if (sub.pending) {
sub.pending = false
for (const relay of connectedRelays) {
sub.sub.add(relay.subscribe(sub.filters, subId, sub.closeAfter))
}
} else {
sub.sub.add(relay.subscribe(sub.filters, subId, sub.closeAfter))
}
}
} }
this.emit('open', relay) this.emit('open', relay)
} }
} }

View File

@ -1,4 +1,5 @@
import {EventKind} from 'src/nostr/model/Event' import {EventKind} from 'src/nostr/model/Event'
import Nip05 from 'src/utils/Nip05'
export default class Profile { export default class Profile {
constructor(pubkey, lastUpdatedAt, metadata) { constructor(pubkey, lastUpdatedAt, metadata) {
@ -10,7 +11,7 @@ export default class Profile {
this.picture = metadata.picture this.picture = metadata.picture
this.nip05 = { this.nip05 = {
url: metadata.nip05, url: metadata.nip05,
verified: false, verified: null,
} }
} }
@ -24,4 +25,20 @@ export default class Profile {
return null return null
} }
} }
async isNip05Verified() {
if (this.nip05.verified !== null) {
return this.nip05.verified
}
if (!this.nip05.url) { // TODO more validation
return false
}
try {
const pubkey = await Nip05.fetchPubkey(this.nip05.url)
this.nip05.verified = pubkey && pubkey === this.pubkey
} catch (e) {
this.nip05.verified = false
}
return this.nip05.verified
}
} }

View File

@ -1,7 +1,7 @@
<template> <template>
<PageHeader /> <PageHeader />
<div class="settings"> <div class="settings">
<h3>Relays</h3> <ProfileSettings />
<RelaySettings /> <RelaySettings />
</div> </div>
</template> </template>
@ -9,18 +9,23 @@
<script> <script>
import PageHeader from 'components/PageHeader.vue' import PageHeader from 'components/PageHeader.vue'
import RelaySettings from 'components/Settings/RelaySettings.vue' import RelaySettings from 'components/Settings/RelaySettings.vue'
import ProfileSettings from 'components/Settings/ProfileSettings.vue'
export default { export default {
name: 'Settings', name: 'Settings',
components: { components: {
PageHeader, PageHeader,
ProfileSettings,
RelaySettings RelaySettings
} }
} }
</script> </script>
<style scoped> <style lang="scss" scoped>
.settings { .settings {
padding: 0 1rem; padding: 0 1rem;
> * + * {
margin-top: 2rem;
}
} }
</style> </style>

View File

@ -15,7 +15,7 @@
<strong>{{ contacts?.length || 0 }}</strong> Following <strong>{{ contacts?.length || 0 }}</strong> Following
</a> </a>
<a @click="goToFollowers('followers')"> <a @click="goToFollowers('followers')">
<strong>{{ followers?.length || 0 }}</strong> Followers <strong>{{ `${followers?.length}+` || 0 }}</strong> Followers
</a> </a>
</p> </p>
</div> </div>
@ -151,8 +151,8 @@ export default defineComponent({
}, },
mounted() { mounted() {
// FIXME // FIXME
this.nostr.fetchNotesByAuthor(this.pubkey) this.nostr.fetchNotesByAuthor(this.pubkey, 50)
this.nostr.fetchReactionsByAuthor(this.pubkey, 100) this.nostr.fetchReactionsByAuthor(this.pubkey, 50)
this.nostr.fetchFollowers(this.pubkey, 1000) this.nostr.fetchFollowers(this.pubkey, 1000)
this.stream = this.nostr.streamFullProfile(this.pubkey) this.stream = this.nostr.streamFullProfile(this.pubkey)
}, },

17
src/utils/Nip05.js Normal file
View File

@ -0,0 +1,17 @@
import fetch from 'cross-fetch'
export default class Nip05 {
static async fetchPubkey(nip05Id) {
const [user, host] = nip05Id.split('@')
const url = `https://${host}/.well-known/nostr.json?name=${user}`
try {
const res = await fetch(url)
const json = await res.json()
console.log('nip05 data', json)
return json.names[user]
} catch (e) {
console.error(`Failed to fetch NIP05 data for ${nip05Id}`, e)
throw e
}
}
}