mirror of
https://github.com/styppo/hamstr.git
synced 2024-10-18 05:23:28 +00:00
* Basic tag rendering in posts
* Refactor nostr fetch logic
This commit is contained in:
parent
2ac3c9ab7d
commit
90a2abc7a3
@ -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>
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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() {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -20,7 +20,7 @@
|
||||
<span>·</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;
|
||||
}
|
||||
|
@ -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
|
100
src/components/Post/Renderer/PostRenderer.vue
Normal file
100
src/components/Post/Renderer/PostRenderer.vue
Normal 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>
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="app.isSignedIn"
|
||||
class="btn"
|
||||
class="btn btn-sm"
|
||||
:class="{'btn-primary': !isFollowing}"
|
||||
>
|
||||
{{ isFollowing ? 'Unfollow' : 'Follow' }}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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 id = this.fnGetId(event)
|
||||
delete this.queue[id]
|
||||
filteredIds.splice(filteredIds.indexOf(id), 1)
|
||||
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
|
||||
|
||||
// console.log(`Fetched ${this.subId} ${id}, ${filteredIds.length} remaining`)
|
||||
delete this.queue[id]
|
||||
filteredIds.splice(filteredIds.indexOf(id), 1)
|
||||
|
||||
this.emit('event', event, relay)
|
||||
console.log(`Fetched ${this.subId} ${id}, ${filteredIds.length} remaining`)
|
||||
|
||||
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
|
||||
} else if (filteredIds.length === 0) {
|
||||
// console.log(`Batch ${this.subId} fetched, requesting more (${Object.keys(this.queue).length} remain)`)
|
||||
this.fetch()
|
||||
}
|
||||
},
|
||||
{
|
||||
subId: this.subId,
|
||||
this.emit('event', event, relay)
|
||||
|
||||
if (Object.keys(this.queue).length === 0) {
|
||||
if (this.retryInterval) clearInterval(this.retryInterval)
|
||||
this.fetching = false
|
||||
sub.close()
|
||||
} else if (filteredIds.length === 0) {
|
||||
this.fetch()
|
||||
}
|
||||
)
|
||||
})
|
||||
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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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,13 +91,8 @@ 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)
|
||||
}
|
||||
const notes = useNoteStore()
|
||||
return notes.addEvent(event)
|
||||
}
|
||||
case EventKind.RELAY:
|
||||
break
|
||||
@ -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,124 +273,79 @@ 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 known = this.hasEvent(event.id)
|
||||
const obj = this.addEvent(event, relay)
|
||||
if (!obj || known) return
|
||||
|
||||
if (eventCallback) eventCallback(obj, relay)
|
||||
|
||||
if (++numEventsSeen >= initialFetchSize && !initialFetchComplete) {
|
||||
initialFetchComplete = true
|
||||
if (initialFetchCompleteCallback) initialFetchCompleteCallback()
|
||||
}
|
||||
},
|
||||
{
|
||||
subId: opts.subId || null,
|
||||
cancelAfter: 'never',
|
||||
eoseCallback: () => {
|
||||
if (!initialFetchComplete) {
|
||||
initialFetchComplete = true
|
||||
if (initialFetchCompleteCallback) initialFetchCompleteCallback()
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
if (eventCallback) eventCallback(obj, relay)
|
||||
|
||||
if (++numEventsSeen >= initialFetchSize && !initialFetchComplete) {
|
||||
initialFetchComplete = true
|
||||
clearTimeout(timer)
|
||||
if (initialFetchCompleteCallback) initialFetchCompleteCallback()
|
||||
}
|
||||
})
|
||||
sub.on('complete', () => {
|
||||
if (!initialFetchComplete) {
|
||||
initialFetchComplete = true
|
||||
clearTimeout(timer)
|
||||
if (initialFetchCompleteCallback) initialFetchCompleteCallback()
|
||||
}
|
||||
})
|
||||
|
||||
return sub
|
||||
}
|
||||
},
|
||||
})
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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]
|
||||
|
@ -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]
|
||||
|
@ -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,
|
||||
}
|
||||
},
|
||||
|
@ -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()}`)
|
||||
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
@ -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')}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user