* Basic tag rendering in posts

* Refactor nostr fetch logic
This commit is contained in:
styppo 2023-01-12 18:14:09 +00:00
parent 2ac3c9ab7d
commit 90a2abc7a3
No known key found for this signature in database
GPG Key ID: 3AAA685C50724C28
25 changed files with 599 additions and 316 deletions

View File

@ -6,7 +6,7 @@
</template>
<script>
import {hexToBech32} from 'src/utils/utils'
import {hexToBech32, shortenBech32} from 'src/utils/utils'
export default {
name: 'Bech32Label',
@ -34,14 +34,9 @@ export default {
const value = this.bech32
? this.bech32.substring(4)
: hexToBech32(this.hex, '')
return this.shorten(value)
return shortenBech32(value)
},
},
methods: {
shorten(str) {
return str.substr(0, 5) + '…' + str.substr(-5)
}
}
}
</script>

View File

@ -126,7 +126,7 @@ export default {
const p = []
if (this.ancestor) {
if (this.ancestor.isReply()) {
if (this.ancestor.hasAncestor()) {
e.push(this.ancestor.root())
}
e.push(this.ancestor.id)

View File

@ -16,7 +16,7 @@
<div class="addon">
<slot name="addon" />
</div>
<router-link class="logo" to="/">
<router-link v-if="logo" class="logo" to="/">
<Logo />
</router-link>
</div>
@ -45,7 +45,11 @@ export default defineComponent({
backButton: {
type: Boolean,
default: false,
}
},
logo: {
type: Boolean,
default: false,
},
},
methods: {
titleFromRoute() {

View File

@ -13,7 +13,7 @@
</div>
<div class="post-content">
<div class="post-content-header">
<p v-if="note.isReply()" class="in-reply-to">
<p v-if="note.hasAncestor()" class="in-reply-to">
Replying to
<a @click.stop="goToProfile(ancestor?.author)">
<UserName v-if="ancestor?.author" :pubkey="ancestor?.author" />
@ -22,7 +22,7 @@
</div>
<div class="post-content-body">
<p>
<BaseMarkdown :content="note.content" />
<PostRenderer :note="note" />
</p>
</div>
<div class="post-content-footer">
@ -59,9 +59,9 @@
<script>
import BaseIcon from 'components/BaseIcon'
import UserAvatar from 'components/User/UserAvatar.vue'
import UserName from 'components/User/UserName.vue'
import BaseMarkdown from 'components/Post/BaseMarkdown.vue'
import UserAvatar from 'components/User/UserAvatar.vue'
import PostRenderer from 'components/Post/Renderer/PostRenderer.vue'
import PostEditor from 'components/CreatePost/PostEditor.vue'
import {useNostrStore} from 'src/nostr/NostrStore'
import {useAppStore} from 'stores/App'
@ -73,11 +73,11 @@ export default {
name: 'HeroPost',
mixins: [routerMixin],
components: {
BaseMarkdown,
BaseIcon,
UserName,
UserAvatar,
BaseIcon,
PostEditor,
PostRenderer,
},
props: {
note: {
@ -98,7 +98,7 @@ export default {
},
computed: {
ancestor() {
return this.note.isReply()
return this.note.hasAncestor()
? this.nostr.getNote(this.note.ancestor())
: null
},
@ -149,6 +149,7 @@ export default {
margin: 0 0 8px;
a {
color: $color-primary;
cursor: pointer;
&:hover {
text-decoration: underline;
}

View File

@ -20,7 +20,7 @@
<span>&#183;</span>
<span class="created-at">{{ formatPostDate(note.createdAt) }}</span>
</p>
<p v-if="note.isReply()" class="in-reply-to">
<p v-if="note.hasAncestor()" class="in-reply-to">
Replying to
<a @click.stop="goToProfile(ancestor?.author)">
<UserName v-if="ancestor?.author" :pubkey="ancestor?.author" />
@ -28,7 +28,7 @@
</p>
</div>
<div class="post-content-body">
<BaseMarkdown :content="note.content" />
<PostRenderer :note="note" />
</div>
<div v-if="showActions" class="post-content-actions">
<div class="action-item comment" @click.stop="app.createPost({ancestor: note.id})">
@ -52,12 +52,12 @@
import BaseIcon from 'components/BaseIcon'
import UserAvatar from 'components/User/UserAvatar.vue'
import UserName from 'components/User/UserName.vue'
import BaseMarkdown from 'components/Post/BaseMarkdown.vue'
import routerMixin from 'src/router/mixin'
import PostRenderer from 'components/Post/Renderer/PostRenderer.vue'
import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import DateUtils from 'src/utils/DateUtils'
import {useStatStore} from 'src/nostr/store/StatStore'
import routerMixin from 'src/router/mixin'
import DateUtils from 'src/utils/DateUtils'
export default {
name: 'ListPost',
@ -65,7 +65,7 @@ export default {
components: {
UserAvatar,
UserName,
BaseMarkdown,
PostRenderer,
BaseIcon,
},
props: {
@ -99,7 +99,7 @@ export default {
},
computed: {
ancestor() {
return this.note.isReply()
return this.note.hasAncestor()
? this.nostr.getNote(this.note.ancestor())
: null
},
@ -165,6 +165,7 @@ export default {
color: $color-dark-gray;
a {
color: $color-primary;
cursor: pointer;
&:hover {
text-decoration: underline;
}

View File

@ -28,7 +28,7 @@ import superscript from 'markdown-it-sup'
import deflist from 'markdown-it-deflist'
import emoji from 'markdown-it-emoji'
import * as Bolt11Decoder from 'light-bolt11-decoder'
import BaseInvoice from 'components/post/BaseInvoice.vue'
import BaseInvoice from 'components/Post/Renderer/BaseInvoice.vue'
const md = MarkdownIt({
html: false,
@ -194,7 +194,7 @@ export default {
props: {
content: {
type: String,
default: 'todo needs to be updated'
required: true,
},
longForm: {
type: Boolean,
@ -227,6 +227,12 @@ export default {
this.render()
},
watch: {
content() {
this.render()
}
},
methods: {
render() {
this.html = md.render(this.parsedContent) + this.$refs.append.innerHTML

View File

@ -0,0 +1,100 @@
<template>
<BaseMarkdown :content="content" />
</template>
<script>
import BaseMarkdown from 'components/Post/Renderer/BaseMarkdown.vue'
import {useNostrStore} from 'src/nostr/NostrStore'
import {TagType} from 'src/nostr/model/Event'
import {hexToBech32, shortenBech32} from 'src/utils/utils'
import routerMixin from 'src/router/mixin'
export default {
name: 'PostRenderer',
components: {BaseMarkdown},
mixins: [routerMixin],
props: {
note: {
type: Object,
required: true,
}
},
setup() {
return {
nostr: useNostrStore(),
}
},
computed: {
content() {
let content = this.note.content
content = this.replaceBech32Refs(content)
content = this.replaceTagRefs(content)
return content
},
profiles() {
const profiles = {}
for (const pubkey of this.note.pubkeyRefs()) {
// TODO batch request
profiles[hexToBech32(pubkey, 'npub')] = this.nostr.getProfile(pubkey)
}
return profiles
},
},
methods: {
replaceTagRefs(str) {
if (!str) return str
return str.replace(/#\[([0-9]+)]/ig, (match, index) => {
const tag = this.note.tags[index]
if (!tag) return match
const rendered = this.renderTag(tag)
if (!rendered) return match
return rendered
})
},
renderTag(tag) {
switch (tag.type) {
case TagType.PUBKEY: {
const bech32 = hexToBech32(tag.ref, 'npub')
return this.renderProfileRef(bech32)
}
case TagType.EVENT: {
const bech32 = hexToBech32(tag.ref, 'note')
return this.renderNoteRef(bech32)
}
}
},
replaceBech32Refs(str) {
if (!str) return str
return str.replace(/@((npub|note)[a-z0-9]{59})/ig, (match, bech32, prefix) => {
switch (prefix) {
case 'npub':
return this.renderProfileRef(bech32)
case 'note':
return this.renderNoteRef(bech32)
default:
return match
}
})
},
renderProfileRef(bech32) {
const profile = this.profiles[bech32]
const text = profile
? profile.name
: shortenBech32(bech32)
if (!bech32 || !text) return
const link = this.linkToProfile(bech32)
return `[${text}](${link})`
},
renderNoteRef(bech32) {
if (!bech32) return
const text = shortenBech32(bech32)
const link = this.linkToThread(bech32)
return `[${text}](${link})`
},
},
}
</script>
<style scoped>
</style>

View File

@ -1,7 +1,7 @@
<template>
<button
v-if="app.isSignedIn"
class="btn"
class="btn btn-sm"
:class="{'btn-primary': !isFollowing}"
>
{{ isFollowing ? 'Unfollow' : 'Follow' }}

View File

@ -31,10 +31,15 @@ h3 {
letter-spacing: unset;
}
a {
cursor: pointer;
}
.btn {
display: block;
text-align: center;
height: fit-content;
padding: 1rem 2rem;
text-align: center;
cursor: pointer;
background-color: transparent;
color: #fff;
@ -68,6 +73,7 @@ h3 {
&-sm {
padding: 8px 16px;
font-weight: 500;
font-weight: 700;
font-size: 1rem;
}
}

View File

@ -9,7 +9,7 @@ export default class FetchQueue extends Observable {
this.fnCreateFilter = fnCreateFilter
this.throttle = opts.throttle || 250
this.batchSize = opts.batchSize || 50
this.retryDelay = opts.retryDelay || 3000
this.retryDelay = opts.retryDelay || 5000
this.maxRetries = opts.maxRetries || 3
this.queue = {}
@ -19,7 +19,7 @@ export default class FetchQueue extends Observable {
this.retryInterval = null
// XXX
setInterval(() => this.failed = {}, 5000)
setInterval(() => this.failed = {}, 10000)
}
add(id) {
@ -29,9 +29,7 @@ export default class FetchQueue extends Observable {
if (this.failed[id]) return // TODO improve this
this.queue[id] = 0
if (!this.fetching) {
this.fetch()
} else if (!this.fetchQueued) {
if (!this.fetching && !this.fetchQueued) {
setTimeout(this.fetch.bind(this), this.throttle)
this.fetchQueued = true
}
@ -44,8 +42,6 @@ export default class FetchQueue extends Observable {
const ids = Object.keys(this.queue).slice(0, this.batchSize)
if (!ids.length) return
//console.log(`Fetching ${ids.length}/${Object.keys(this.queue).length} ${this.subId}s`, ids)
// Remove ids that we have tried too many times.
const filteredIds = []
for (const id of ids) {
@ -58,38 +54,51 @@ export default class FetchQueue extends Observable {
filteredIds.push(id)
}
}
if (!filteredIds.length) return
console.log(`Fetching ${ids.length}/${Object.keys(this.queue).length} ${this.subId}s`, ids)
this.fetching = true
this.retryInterval = setInterval(this.fetch.bind(this), this.retryDelay)
// XXX Needed for some relays?
this.client.unsubscribe(this.subId)
//this.client.unsubscribe(this.subId)
this.client.subscribe(
this.fnCreateFilter(filteredIds),
(event, relay, subId) => {
const sub = this.client.subscribe(this.fnCreateFilter(filteredIds), this.subId)
sub.on('event', (event, relay, subId) => {
const id = this.fnGetId(event)
if (!this.queue[id]) return
delete this.queue[id]
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)
if (Object.keys(this.queue).length === 0) {
// console.log(`Fetched all ${this.subId}s`)
if (this.retryInterval) clearInterval(this.retryInterval)
this.client.unsubscribe(subId)
this.fetching = false
sub.close()
} else if (filteredIds.length === 0) {
// console.log(`Batch ${this.subId} fetched, requesting more (${Object.keys(this.queue).length} remain)`)
this.fetch()
}
},
{
subId: this.subId,
})
sub.on('complete', () => {
if (this.fetching && Object.keys(this.queue).length > 0) {
this.fetch()
} else {
console.log('[COMPLETE]', this)
if (this.retryInterval) clearInterval(this.retryInterval)
this.fetching = false
sub.close()
}
)
})
sub.on('close', () => {
if (this.fetching && Object.keys(this.queue).length > 0) {
this.fetch()
}
})
}
}

View File

@ -1,22 +1,11 @@
import RelayPool from 'src/nostr/RelayPool'
import Event from 'src/nostr/model/Event'
export const CancelAfter = {
SINGLE: 'single',
EOSE: 'eose',
NEVER: 'never',
}
import {CloseAfter} from 'src/nostr/Relay'
export default class NostrClient {
constructor(relays) {
this.pool = new RelayPool(relays)
this.pool.on('event', this.onEvent.bind(this))
this.pool.on('eose', this.onEose.bind(this))
this.pool.on('notice', this.onNotice.bind(this))
this.pool.on('ok', this.onOk.bind(this))
this.subs = {}
this.nextSubId = 0
}
connect() {
@ -31,70 +20,57 @@ export default class NostrClient {
return this.pool.connectedRelays()
}
subscribe(filters, callback, opts = {}) {
let subId
if (opts?.subId) {
//if (this.subs[opts.subId]) throw new Error(`SubId '${opts.subId}' already exists`)
subId = opts.subId
} else {
subId = `sub${this.nextSubId++}`
}
this.subs[subId] = {
eventCallback: callback,
eoseCallback: opts.eoseCallback,
cancelAfter: opts.cancelAfter || CancelAfter.NEVER,
}
this.pool.subscribe(subId, filters)
return subId
subscribe(filters, subId = null) {
return this.pool.subscribe(filters, subId)
}
unsubscribe(subId) {
this.pool.unsubscribe(subId)
delete this.subs[subId]
}
send(event) {
return this.pool.send(event)
publish(event) {
return this.pool.publish(event)
}
onEvent(relay, subId, ev) {
const event = Event.from(ev)
if (!event.validate()) {
// TODO Close relay?
console.error(`Invalid event from ${relay}`, event)
return
}
// fetchSingle(filters) {
// const filtersWithLimit = Object.assign({}, filters, {limit: 1})
// return new Promise(resolve => {
// const sub = this.pool.subscribe(filters)
// sub.on('event')
// this.client.subscribe(
// filtersWithLimit,
// (event, relay) => {
// resolve(this.addEvent(event, relay))
// },
// {
// closeAfter: 'single'
// }
// )
// })
// }
const sub = this.subs[subId]
if (!sub) {
//console.warn(`Event for invalid subId ${subId} from ${relay}`)
return
}
if (typeof sub.eventCallback === 'function') {
sub.eventCallback(event, relay, subId)
}
if (sub.cancelAfter === CancelAfter.SINGLE) {
this.unsubscribe(subId)
}
}
onEose(relay, subId) {
//console.log(`[EOSE] from ${relay} for ${subId}`)
const sub = this.subs[subId]
if (!sub) return
if (typeof sub.eoseCallback === 'function') {
sub.eoseCallback(relay, subId)
}
if (sub.cancelAfter === CancelAfter.EOSE) {
this.unsubscribe(subId)
fetchMultiple(filters, limit = 100, timeout = 5000) {
return new Promise(resolve => {
const objects = {}
const filtersWithLimit = Object.assign({}, filters, {limit})
const sub = this.pool.subscribe(filtersWithLimit, null, CloseAfter.EOSE)
const timer = setTimeout(() => {
sub.close()
resolve(objects)
}, timeout)
sub.on('event', event => {
objects[event.id] = event
if (Object.keys(objects).length >= limit) {
clearTimeout(timer)
sub.close()
resolve(objects)
}
})
sub.on('close', () => {
clearTimeout(timer)
resolve(objects)
})
})
}
onNotice(relay, message) {

View File

@ -1,15 +1,14 @@
import {defineStore} from 'pinia'
import {markRaw} from 'vue'
import {EventKind} from 'src/nostr/model/Event'
import Note from 'src/nostr/model/Note'
import NostrClient from 'src/nostr/NostrClient'
import FetchQueue from 'src/nostr/FetchQueue'
import {NoteOrder, useNoteStore} from 'src/nostr/store/NoteStore'
import {useProfileStore} from 'src/nostr/store/ProfileStore'
import {useContactStore} from 'src/nostr/store/ContactStore'
import {useSettingsStore} from 'stores/Settings'
import {ReactionOrder, useReactionStore} from 'src/nostr/store/ReactionStore'
import {useStatStore} from 'src/nostr/store/StatStore'
import {CloseAfter} from 'src/nostr/Relay'
export const Feeds = {
GLOBAL: {
@ -92,14 +91,9 @@ export const useNostrStore = defineStore('nostr', {
return profiles.addEvent(event)
}
case EventKind.NOTE: {
if (Note.isReaction(event)) {
const reactions = useReactionStore()
return reactions.addEvent(event)
} else {
const notes = useNoteStore()
return notes.addEvent(event)
}
}
case EventKind.RELAY:
break
case EventKind.CONTACT: {
@ -113,8 +107,8 @@ export const useNostrStore = defineStore('nostr', {
case EventKind.SHARE:
break
case EventKind.REACTION: {
const reactions = useReactionStore()
return reactions.addEvent(event)
const notes = useNoteStore()
return notes.addEvent(event)
}
case EventKind.CHATROOM:
break
@ -127,7 +121,7 @@ export const useNostrStore = defineStore('nostr', {
sendEvent(event) {
this.addEvent(event)
return this.client.send(event)
return this.client.publish(event)
},
getProfile(pubkey) {
@ -139,7 +133,7 @@ export const useNostrStore = defineStore('nostr', {
getNote(id) {
const notes = useNoteStore()
const note = notes.get(id)
let note = notes.get(id)
if (!note) this.noteQueue.add(id)
return note
},
@ -154,7 +148,7 @@ export const useNostrStore = defineStore('nostr', {
getNotesByAuthor(pubkey, opts = {}) {
const order = opts.order || NoteOrder.CREATION_DATE_DESC
const notes = useNoteStore()
return notes.allByAuthor(pubkey, order)
return notes.getNotesByAuthor(pubkey, order)
},
fetchNotesByAuthor(pubkey, opts = {}) {
@ -193,9 +187,9 @@ export const useNostrStore = defineStore('nostr', {
)
},
getReactionsTo(id, order = ReactionOrder.CREATION_DATE_DESC) {
const store = useReactionStore()
const reactions = store.allByEvent(id, order)
getReactionsTo(id, order = NoteOrder.CREATION_DATE_DESC) {
const store = useNoteStore()
const reactions = store.reactionsTo(id, order)
// TODO fetch?
return reactions
},
@ -210,9 +204,9 @@ export const useNostrStore = defineStore('nostr', {
)
},
getReactionsByAuthor(pubkey, order = ReactionOrder.CREATION_DATE_DESC) {
const store = useReactionStore()
const reactions = store.allByAuthor(pubkey, order)
getReactionsByAuthor(pubkey, order = NoteOrder.CREATION_DATE_DESC) {
const store = useNoteStore()
const reactions = store.getReactionsByAuthor(pubkey, order)
// TODO fetch?
return reactions
},
@ -227,6 +221,30 @@ export const useNostrStore = defineStore('nostr', {
)
},
fetchMultiple(filters, limit = 100, timeout = 5000) {
return new Promise(resolve => {
const objects = {}
const filtersWithLimit = Object.assign({}, filters, {limit})
const sub = this.client.subscribe(filtersWithLimit, null, CloseAfter.EOSE)
const timer = setTimeout(() => {
sub.close()
resolve(objects)
}, timeout)
sub.on('event', event => {
objects[event.id] = this.addEvent(event)
if (Object.keys(objects).length >= limit) {
clearTimeout(timer)
sub.close()
resolve(objects)
}
})
sub.on('close', () => {
clearTimeout(timer)
resolve(objects)
})
})
},
streamThread(rootId, eventCallback, initialFetchCompleteCallback) {
return this.streamEvents(
{
@ -255,102 +273,58 @@ export const useNostrStore = defineStore('nostr', {
},
streamFullProfile(pubkey) {
const handles = []
// Everything authored by pubkey
handles.push(this.client.subscribe({
kinds: [EventKind.NOTE],
authors: [pubkey],
limit: 200,
}, () => {}, { subId: 'foo' }))
handles.push(this.client.subscribe({
kinds: [EventKind.REACTION],
authors: [pubkey],
limit: 100,
}))
handles.push(this.client.subscribe({
kinds: [EventKind.METADATA, EventKind.CONTACT],
authors: [pubkey],
}))
// FIXME
// const handles = []
// // Everything authored by pubkey
// handles.push(this.client.subscribe({
// kinds: [EventKind.METADATA, EventKind.NOTE, EventKind.RELAY, EventKind.CONTACT, EventKind.REACTION],
// kinds: [EventKind.NOTE],
// authors: [pubkey],
// limit: 200,
// }, () => {}, { subId: 'foo' }))
// handles.push(this.client.subscribe({
// kinds: [EventKind.REACTION],
// authors: [pubkey],
// limit: 100,
// }))
// handles.push(this.client.subscribe({
// kinds: [EventKind.METADATA, EventKind.CONTACT],
// authors: [pubkey],
// }))
// Followers
handles.push(this.client.subscribe({
kinds: [EventKind.CONTACT],
'#p': [pubkey]
}))
return handles
// // handles.push(this.client.subscribe({
// // kinds: [EventKind.METADATA, EventKind.NOTE, EventKind.RELAY, EventKind.CONTACT, EventKind.REACTION],
// // authors: [pubkey],
// // }))
// // Followers
// handles.push(this.client.subscribe({
// kinds: [EventKind.CONTACT],
// '#p': [pubkey],
// limit: 2000,
// }))
// return handles
},
cancelStream(subIds) {
// FIXME
if (!Array.isArray(subIds)) subIds = [subIds]
for (const subId of subIds) {
this.client.unsubscribe(subId)
}
},
fetchEvent(id) {
this.fetchSingle({
ids: [id],
})
},
fetchSingle(filters) {
const filtersWithLimit = Object.assign({}, filters, {limit: 1})
return new Promise(resolve => {
this.client.subscribe(
filtersWithLimit,
(event, relay) => {
resolve(this.addEvent(event, relay))
},
{
cancelAfter: 'single'
}
)
})
},
fetchMultiple(filters, limit = 100) {
const filtersWithLimit = Object.assign({}, filters, {limit})
let unsubscribeTimeout
return new Promise(resolve => {
const objects = []
this.client.subscribe(
filtersWithLimit,
(event, relay, subId) => {
const obj = this.addEvent(event, relay)
if (!obj) return
// TODO Deduplicate
objects.push(obj)
if (objects.length >= limit) {
this.client.unsubscribe(subId)
resolve(objects)
}
},
{
eoseCallback: (_relay, subId) => {
if (unsubscribeTimeout) clearTimeout(unsubscribeTimeout)
unsubscribeTimeout = setTimeout(() => {
this.client.unsubscribe(subId)
resolve(objects)
}, 250)
}
}
)
})
},
streamEvents(filters, initialFetchSize, eventCallback, initialFetchCompleteCallback, opts) {
const filtersWithLimit = Object.assign({}, filters, {limit: initialFetchSize})
let numEventsSeen = 0
let initialFetchComplete = false
return this.client.subscribe(
filtersWithLimit,
(event, relay) => {
const sub = this.client.subscribe(filtersWithLimit, opts.subId || null)
const timer = setTimeout(() => {
if (!initialFetchComplete) {
initialFetchComplete = true
if (initialFetchCompleteCallback) initialFetchCompleteCallback()
}
}, opts.timeout || 5000)
sub.on('event', (event, relay) => {
const known = this.hasEvent(event.id)
const obj = this.addEvent(event, relay)
if (!obj || known) return
@ -359,20 +333,19 @@ export const useNostrStore = defineStore('nostr', {
if (++numEventsSeen >= initialFetchSize && !initialFetchComplete) {
initialFetchComplete = true
clearTimeout(timer)
if (initialFetchCompleteCallback) initialFetchCompleteCallback()
}
},
{
subId: opts.subId || null,
cancelAfter: 'never',
eoseCallback: () => {
})
sub.on('complete', () => {
if (!initialFetchComplete) {
initialFetchComplete = true
clearTimeout(timer)
if (initialFetchCompleteCallback) initialFetchCompleteCallback()
}
}
}
)
})
return sub
}
},
})

View File

@ -1,4 +1,51 @@
import {Observable} from 'src/nostr/utils'
import Event from 'src/nostr/model/Event'
export class Subscription extends Observable {
constructor(relay, subId, closeAfter) {
super()
this.relay = relay
this.subId = subId
this.closeAfter = closeAfter
this.closed = false
}
close() {
if (this.closed) return
this.closed = true
this.relay.unsubscribe(this.subId)
this.emit('close', this.relay, this.subId)
}
onEvent(ev) {
const event = Event.from(ev)
if (!event.validate()) {
// TODO Close relay?
console.error(`Invalid event from ${this.relay}`, event)
return
}
this.emit('event', event, this.relay, this.subId)
if (this.closeAfter === CloseAfter.SINGLE) {
this.close()
}
}
onEose() {
this.emit('eose', this.relay, this.subId)
if (this.closeAfter === CloseAfter.EOSE) {
this.close()
}
}
}
export const CloseAfter = {
SINGLE: 'single',
EOSE: 'eose',
NEVER: 'never',
}
export class Relay extends Observable {
constructor(url, opts) {
@ -10,6 +57,9 @@ export class Relay extends Observable {
this.socket.on('close', this.emit.bind(this, 'close', this))
this.socket.on('error', this.emit.bind(this, 'error', this))
this.socket.on('message', this.onMessage.bind(this))
this.subs = {}
this.nextSubId = 0
}
connect() {
@ -24,16 +74,21 @@ export class Relay extends Observable {
return this.socket.isConnected()
}
send(event) {
publish(event) {
this.socket.send(['EVENT', event])
}
subscribe(subId, filters) {
console.log(`${this} subscribing to ${subId}`, filters)
subscribe(filters, subId = null, closeAfter = CloseAfter.NEVER) {
if (!subId) subId = `sub${this.nextSubId++}`
const sub = new Subscription(this, subId, closeAfter)
this.subs[subId] = sub
this.socket.send(['REQ', subId, filters])
return sub
}
unsubscribe(subId) {
if (!this.subs[subId]) return
delete this.subs[subId]
this.socket.send(['CLOSE', subId])
}
@ -49,7 +104,7 @@ export class Relay extends Observable {
processMessage(msg) {
const array = JSON.parse(msg)
if (!Array.isArray(array) || array.length === 0) {
if (!Array.isArray(array) || !array.length) {
throw new Error('not a nostr message')
}
@ -58,12 +113,22 @@ export class Relay extends Observable {
case 'EVENT': {
Relay.enforceArrayLength(array, 3)
const [_, subId, event] = array
const sub = this.subs[subId]
if (sub) sub.onEvent(event)
// still needed?
this.emit('event', subId, event)
break
}
case 'EOSE': {
Relay.enforceArrayLength(array, 2)
const [_, subId] = array
const sub = this.subs[subId]
if (sub) sub.onEose()
// still needed?
this.emit('eose', subId)
break
}

View File

@ -1,12 +1,70 @@
import {Relay} from 'src/nostr/Relay'
import {CloseAfter, Relay} from 'src/nostr/Relay'
import {Observable} from 'src/nostr/utils'
export default class extends Observable {
class MultiSubscription extends Observable {
constructor(subId, subs) {
super()
this.subId = subId
this.subs = {}
for (const sub of subs) {
this.subs[sub.relay] = sub
this.setupListeners(sub)
}
}
setupListeners(sub) {
sub.on('event', this.emit.bind(this, 'event'))
sub.on('eose', this.onEose.bind(this))
sub.on('close', this.onClose.bind(this))
}
add(sub) {
console.assert(sub.subId === this.subId, 'invalid subId')
this.subs[sub.relay] = sub
this.setupListeners(sub)
}
close(relay = null) {
if (relay) {
if (this.subs[relay]) this.subs[relay].close()
return
}
for (const sub of Object.values(this.subs)) {
sub.close()
}
}
onEose(relay) {
const sub = this.subs[relay]
if (!sub) return
// still needed?
this.emit('eose', relay, this.subId)
if (!sub.eoseSeen) {
sub.eoseSeen = true
if (Object.values(this.subs).every(sub => sub.eoseSeen)) {
this.emit('complete', this.subId)
}
}
}
onClose(relay) {
delete this.subs[relay]
if (Object.keys(this.subs).length === 0) {
this.emit('close', this.subId)
}
}
}
export default class ReplayPool extends Observable {
constructor(urls) {
super()
this.relays = {}
this.subs = {}
this.nextSubId = 0
for (const url of urls) {
this.add(url)
@ -35,25 +93,30 @@ export default class extends Observable {
delete this.relays[url]
}
send(event) {
publish(event) {
for (const relay of this.connectedRelays()) {
relay.send(event)
relay.publish(event)
}
}
subscribe(subId, filters) {
console.log(`Subscribing ${subId}`, filters)
this.subs[subId] = filters
subscribe(filters, subId = null, closeAfter = CloseAfter.NEVER) {
if (!subId) subId = `sub${this.nextSubId++}`
const sub = new MultiSubscription(subId, [])
sub.on('close', this.unsubscribe.bind(this, subId))
this.subs[subId] = {sub, filters, closeAfter}
for (const relay of this.connectedRelays()) {
relay.subscribe(subId, filters)
sub.add(relay.subscribe(filters, subId, closeAfter))
}
return sub
}
unsubscribe(subId) {
const sub = this.subs[subId]
if (!sub) return
sub.sub.close()
delete this.subs[subId]
for (const relay of this.connectedRelays()) {
relay.unsubscribe(subId)
}
}
connect() {
@ -75,8 +138,8 @@ export default class extends Observable {
onOpen(relay) {
console.log(`Connected to ${relay}`, relay)
for (const subId of Object.keys(this.subs)) {
// console.log(`Subscribing ${subId} with ${relay}`, this.subs[subId])
relay.subscribe(subId, this.subs[subId])
const sub = this.subs[subId]
sub.sub.add(relay.subscribe(sub.filters, subId, sub.closeAfter))
}
this.emit('open', relay)
}

View File

@ -12,12 +12,31 @@ export const EventKind = {
CHATROOM: 42,
}
export const Tag = {
export const TagType = {
PUBKEY: 'p',
EVENT: 'e',
}
class EventRefs extends Array {
export class Tag {
constructor(type, ref, relay = null, meta = null) {
this.type = type
this.ref = ref
this.relay = relay
this.meta = meta
}
static from(array) {
if (!array || !array[0] || !array[1]) return
return new Tag(
array[0],
array[1],
array[2] || null,
array[3] || null,
)
}
}
export class EventRefs extends Array {
constructor(refs) {
// FIXME limit number of refs here
super(...refs)
@ -42,7 +61,7 @@ export default class Event {
this.pubkey = opts.pubkey
this.created_at = opts.createdAt || opts.created_at
this.kind = opts.kind
this.tags = opts.tags || []
this.tags = Event.parseTags(opts.tags || [])
this.content = opts.content
this.sig = opts.sig
}
@ -56,6 +75,16 @@ export default class Event {
return new Event(opts)
}
static parseTags(tags) {
const res = []
for (const tag of tags) {
const parsed = Tag.from(tag)
if (!parsed) continue
res.push(parsed)
}
return res
}
get createdAt() {
return this.created_at
}
@ -69,16 +98,19 @@ export default class Event {
return getEventHash(this)
}
pubkeyTags() {
return this.tags.filter(tag => tag.type === TagType.PUBKEY)
}
eventTags() {
return this.tags.filter(tag => tag.type === TagType.EVENT)
}
pubkeyRefs() {
return this.tags
.filter(tag => tag[0] === Tag.PUBKEY && tag[1])
.map(tag => tag[1])
return this.pubkeyTags().map(tag => tag.ref)
}
eventRefs() {
const refs = this.tags
.filter(tag => tag[0] === Tag.EVENT && tag[1])
.map(tag => tag[1])
return new EventRefs(refs)
return new EventRefs(this.eventTags().map(tag => tag.ref))
}
}

View File

@ -1,4 +1,4 @@
import {EventKind} from 'src/nostr/model/Event'
import {EventKind, EventRefs, TagType} from 'src/nostr/model/Event'
import {isEmoji} from 'src/utils/utils'
export default class Note {
@ -7,11 +7,8 @@ export default class Note {
this.kind = args.kind || EventKind.NOTE
this.author = args.author || args.pubkey
this.createdAt = args.createdAt
this.content = args.content
this.refs = {
events: args.refs?.events || [],
pubkeys: args.refs?.pubkeys || [],
}
this.content = args.content || ''
this.tags = args.tags || []
}
static from(event) {
@ -24,15 +21,12 @@ export default class Note {
author: event.pubkey,
createdAt: event.createdAt,
content,
refs: {
events: event.eventRefs(),
pubkeys: event.pubkeyRefs(),
}
tags: event.tags,
})
}
isReply() {
return !this.refs.events.isEmpty()
hasAncestor() {
return !this.eventRefs().isEmpty()
}
canReply() {
@ -40,16 +34,39 @@ export default class Note {
}
root() {
return this.refs.events.root()
return this.eventRefs().root()
}
ancestor() {
return this.refs.events.ancestor()
return this.eventRefs().ancestor()
}
pubkeyRefs() {
return this.tags
.filter(tag => tag.type === TagType.PUBKEY)
.map(tag => tag.ref)
}
eventRefs() {
const refs = this.tags
.filter(tag => tag.type === TagType.EVENT)
.map(tag => tag.ref)
return new EventRefs(refs)
}
contentTagRefs() {
const regex = /#\[([0-9]+)]/ig
let refs = []
let match
while ((match = regex.exec(this.content))) {
refs.push(match[1])
}
return refs
}
isReaction() {
return this.kind === EventKind.REACTION
|| (this.isReply() && Note.isReactionContent(this.content))
|| (this.hasAncestor() && Note.isReactionContent(this.content))
}
static isReaction(event) {

View File

@ -1,5 +1,5 @@
import {defineStore} from 'pinia'
import {EventKind, Tag} from 'src/nostr/model/Event'
import {EventKind} from 'src/nostr/model/Event'
export const useContactStore = defineStore('contact', {
state: () => ({
@ -26,12 +26,11 @@ export const useContactStore = defineStore('contact', {
const newContacts = []
newContacts.lastUpdatedAt = event.createdAt
const tags = event.tags.filter(tag => tag[0] === Tag.PUBKEY && tag[1])
for (const tag of tags) {
for (const tag of event.pubkeyTags()) {
newContacts.push({
pubkey: tag[1],
relay: tag[2],
name: tag[3],
pubkey: tag.ref,
relay: tag.relay,
name: tag.meta,
})
}

View File

@ -10,7 +10,9 @@ export const useNoteStore = defineStore('note', {
state: () => ({
notes: {},
replies: {},
byAuthor: {},
reactions: {},
notesByAuthor: {},
reactionsByAuthor: {},
}),
getters: {
get(state) {
@ -19,9 +21,15 @@ export const useNoteStore = defineStore('note', {
repliesTo(state) {
return (id, order) => (state.replies[id] || []).sort(order || NoteOrder.CREATION_DATE_ASC)
},
allByAuthor(state) {
return (pubkey, order) => (state.byAuthor[pubkey] || []).sort(order || NoteOrder.CREATION_DATE_ASC)
}
reactionsTo(state) {
return (id, order) => (state.reactions[id] || []).sort(order || NoteOrder.CREATION_DATE_ASC)
},
getNotesByAuthor(state) {
return (pubkey, order) => (state.notesByAuthor[pubkey] || []).sort(order || NoteOrder.CREATION_DATE_ASC)
},
getReactionsByAuthor(state) {
return (pubkey, order) => (state.reactionsByAuthor[pubkey] || []).sort(order || NoteOrder.CREATION_DATE_ASC)
},
},
actions: {
addEvent(event) {
@ -30,19 +38,24 @@ export const useNoteStore = defineStore('note', {
// Skip if note already exists
if (this.notes[note.id]) return this.notes[note.id]
this.notes[note.id] = note
if (!this.byAuthor[note.author]) {
this.byAuthor[note.author] = []
const byAuthor = note.isReaction()
? this.reactionsByAuthor
: this.notesByAuthor
if (!byAuthor[note.author]) {
byAuthor[note.author] = []
}
this.byAuthor[note.author].push(note)
byAuthor[note.author].push(note)
if (note.isReply()) {
if (!this.replies[note.ancestor()]) {
this.replies[note.ancestor()] = []
if (note.hasAncestor()) {
const map = note.isReaction()
? this.reactions
: this.replies
if (!map[note.ancestor()]) {
map[note.ancestor()] = []
}
this.replies[note.ancestor()].push(note)
map[note.ancestor()].push(note)
}
return this.notes[note.id]

View File

@ -52,7 +52,7 @@ export const useReactionStore = defineStore('reaction', {
actions: {
addEvent(event) {
const note = Note.from(event)
if (!note || !note.isReply()) return false
if (!note || !note.hasAncestor()) return false
// Skip if reaction already exists
if (this.reactions[note.id]) return this.reactions[note.id]

View File

@ -1,7 +1,7 @@
<template>
<q-page>
<div class="page-header-container">
<PageHeader>
<PageHeader logo>
<template #addon>
<div class="addon-menu">
<div class="addon-menu-icon">
@ -40,7 +40,7 @@
/>
</div>
<Thread v-for="thread in feedItems" :key="thread.id" :thread="thread" class="full-width" />
<Thread v-for="thread in feedItems" :key="thread[0].id" :thread="thread" class="full-width" />
<!-- <ButtonLoadMore-->
<!-- :loading="loadingMore"-->
@ -80,9 +80,9 @@ export default defineComponent({
},
data() {
return {
feeds: {},
availableFeeds: ['global'],
selectedFeed: 'global',
feeds: {},
recentlyLoaded: true,
}
},

View File

@ -73,7 +73,7 @@ export default defineComponent({
},
rootId() {
if (!this.noteLoaded) return
return this.note.isReply()
return this.note.hasAncestor()
? this.note.root()
: this.note.id
},
@ -197,7 +197,7 @@ export default defineComponent({
},
allAncestors(note) {
if (!note.isReply()) return []
if (!note.hasAncestor()) return []
const ancestor = this.nostr.getNote(note.ancestor())
if (!ancestor) {
console.error(`Couldn't fetch ancestor ${note.ancestor()}`)

View File

@ -117,10 +117,10 @@ export default defineComponent({
return this.nostr.getNotesByAuthor(this.pubkey)
},
posts() {
return this.notes.filter(note => !note.isReply())
return this.notes.filter(note => !note.hasAncestor())
},
replies() {
return this.notes.filter(note => note.isReply())
return this.notes.filter(note => note.hasAncestor())
.map(note => [this.nostr.getNote(note.ancestor()), note])
},
reactions() {
@ -143,7 +143,7 @@ export default defineComponent({
this.$router.push({
name: 'followers',
params: {
pubkey: hexToBech32(this.pubkey),
pubkey: hexToBech32(this.pubkey, 'npub'),
tab,
}
})

View File

@ -1,4 +1,10 @@
import {hexToBech32} from 'src/utils/utils'
import {hexToBech32, isBech32} from 'src/utils/utils'
function ensureBech32(str, prefix) {
return isBech32(str)
? str
: hexToBech32(str, prefix)
}
export default {
methods: {
@ -6,7 +12,7 @@ export default {
this.$router.push({
name: 'profile',
params: {
pubkey: hexToBech32(pubkey, 'npub')
pubkey: ensureBech32(pubkey, 'npub')
}
})
},
@ -14,9 +20,16 @@ export default {
this.$router.push({
name: 'thread',
params: {
id: hexToBech32(id, 'note')
id: ensureBech32(id, 'note')
}
})
},
// TODO There must be a better way to do this
linkToProfile(pubkey) {
return `/profile/${ensureBech32(pubkey, 'npub')}`
},
linkToThread(id) {
return `/thread/${ensureBech32(id, 'note')}`
}
}
}

View File

@ -6,6 +6,10 @@ export function isEmoji(str) {
return !/\w/.test(str) && PATTERN_EMOJI.test(str)
}
export function isBech32(str) {
return /^(note|npub|nsec)[a-z0-9]{59}$/i.test(str)
}
export function bech32prefix(bech32) {
if (!bech32 || bech32.length < 4) return
return bech32.substr(0, 4).toLowerCase()
@ -38,3 +42,9 @@ export function hexToBech32(hex, prefix = '') {
}
return bech32encode(prefix, buffer)
}
export function shortenBech32(str) {
if (!str) return str
let keepStart = isBech32(str) ? 9 : 5
return str.substr(0, keepStart) + '…' + str.substr(-5)
}