diff --git a/src/components/AsyncLoadButton.vue b/src/components/AsyncLoadButton.vue new file mode 100644 index 0000000..add7477 --- /dev/null +++ b/src/components/AsyncLoadButton.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/src/components/ButtonLoadMore.vue b/src/components/ButtonLoadMore.vue deleted file mode 100644 index 84ed04c..0000000 --- a/src/components/ButtonLoadMore.vue +++ /dev/null @@ -1,43 +0,0 @@ - - - - - diff --git a/src/components/Post/ListPost.vue b/src/components/Post/ListPost.vue index 51dc0e5..5db1013 100644 --- a/src/components/Post/ListPost.vue +++ b/src/components/Post/ListPost.vue @@ -128,7 +128,7 @@ export default { }, }, mounted() { - const updateInterval = Date.now() / 1000 - this.note.createdAt >= 3600 // 1h + const updateInterval = DateUtils.now() - this.note.createdAt >= 3600 // 1h ? 3600 // 1h : 60 // 1m this.refreshTimer = setInterval(() => this.refreshCounter++, updateInterval * 1000) diff --git a/src/nostr/FetchQueue.js b/src/nostr/FetchQueue.js index 3a2229d..d81f787 100644 --- a/src/nostr/FetchQueue.js +++ b/src/nostr/FetchQueue.js @@ -5,21 +5,19 @@ export default class FetchQueue extends Observable { super() this.client = client this.subId = subId + this.sub = null this.fnGetId = fnGetId this.fnCreateFilter = fnCreateFilter this.throttle = opts.throttle || 250 - this.batchSize = opts.batchSize || 50 - this.retryDelay = opts.retryDelay || 5000 - this.maxRetries = opts.maxRetries || 3 + this.batchSize = opts.batchSize || 250 + this.timeout = opts.timeout || 3000 + this.maxAttempts = opts.maxAttempts || 2 this.queue = {} this.failed = {} this.fetching = false this.fetchQueued = false - this.retryInterval = null - - // XXX - setInterval(() => this.failed = {}, 10000) + this.fetchTimeout = null } add(id) { @@ -37,16 +35,19 @@ export default class FetchQueue extends Observable { fetch() { this.fetchQueued = false - if (this.retryInterval) clearInterval(this.retryInterval) + if (this.fetchTimeout) clearTimeout(this.fetchTimeout) const ids = Object.keys(this.queue).slice(0, this.batchSize) - if (!ids.length) return + if (!ids.length) { + this.fetching = false + return + } // Remove ids that we have tried too many times. const filteredIds = [] for (const id of ids) { this.queue[id]++ - if (this.queue[id] > this.maxRetries) { + if (this.queue[id] > this.maxAttempts) { console.warn(`Failed to fetch ${this.subId} ${id}`) this.failed[id] = true delete this.queue[id] @@ -55,12 +56,15 @@ export default class FetchQueue extends Observable { } } - if (!filteredIds.length) return + if (!filteredIds.length) { + this.fetching = false + return + } - // console.log(`Fetching ${filteredIds.length}/${Object.keys(this.queue).length} ${this.subId}s`, ids) + console.log(`Fetching ${this.subId}s ${filteredIds.length}/${Object.keys(this.queue).length} `) this.fetching = true - this.retryInterval = setInterval(this.fetch.bind(this), this.retryDelay) + this.fetchTimeout = setTimeout(this.fetch.bind(this), this.timeout) // XXX Needed for some relays? //this.client.unsubscribe(this.subId) @@ -78,19 +82,19 @@ export default class FetchQueue extends Observable { this.emit('event', event, relay) if (Object.keys(this.queue).length === 0) { - if (this.retryInterval) clearInterval(this.retryInterval) + if (this.fetchTimeout) clearTimeout(this.fetchTimeout) this.fetching = false sub.close() } else if (filteredIds.length === 0) { this.fetch() } }) - sub.on('complete', () => { + sub.on('end', () => { if (this.fetching && Object.keys(this.queue).length > 0) { this.fetch() } else { console.log('[COMPLETE]', this) - if (this.retryInterval) clearInterval(this.retryInterval) + if (this.fetchTimeout) clearTimeout(this.fetchTimeout) this.fetching = false sub.close() } diff --git a/src/nostr/NostrClient.js b/src/nostr/NostrClient.js index eed402f..a8105d3 100644 --- a/src/nostr/NostrClient.js +++ b/src/nostr/NostrClient.js @@ -25,8 +25,8 @@ export default class NostrClient { return this.connectedRelays().some(relay => relay.url === url) } - subscribe(filters, subId = null) { - return this.pool.subscribe(filters, subId) + subscribe(filters, subId = null, closeAfter = CloseAfter.NEVER) { + return this.pool.subscribe(filters, subId, closeAfter) } unsubscribe(subId) { @@ -37,47 +37,45 @@ export default class NostrClient { return this.pool.publish(event) } - // 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' - // } - // ) - // }) - // } - - fetchMultiple(filters, limit = 100, timeout = 5000) { + fetch(filters, opts = {}) { return new Promise(resolve => { - const objects = {} - const filtersWithLimit = Object.assign({}, filters, {limit}) - const sub = this.pool.subscribe(filtersWithLimit, null, CloseAfter.EOSE) + const events = {} + const sub = this.pool.subscribe(filters, opts.subId, CloseAfter.EOSE) const timer = setTimeout(() => { sub.close() - resolve(Object.values(objects)) - }, timeout) + resolve(Object.values(events)) + }, opts.timeout || 4000) sub.on('event', event => { - objects[event.id] = event + events[event.id] = event }) - sub.on('complete', () => { + sub.on('end', () => { sub.close() clearTimeout(timer) - resolve(Object.values(objects)) + resolve(Object.values(events)) }) sub.on('close', () => { clearTimeout(timer) - resolve(Object.values(objects)) + resolve(Object.values(events)) }) }) } + stream(filters, eventCallback, endCallback = () => {}, opts = {}) { + const events = {} + const sub = this.pool.subscribe(filters, opts.subId) + const timer = setTimeout(() => { + endCallback(Object.values(events)) + }, opts.timeout || 5000) + sub.on('event', event => { + events[event.id] = event + }) + sub.on('end', () => { + clearTimeout(timer) + endCallback(Object.values(events)) + }) + return sub + } + onNotice(relay, message) { console.warn(`[NOTICE] from ${relay}: ${message}`) } diff --git a/src/nostr/NostrStore.js b/src/nostr/NostrStore.js index 5d366c8..8138a7a 100644 --- a/src/nostr/NostrStore.js +++ b/src/nostr/NostrStore.js @@ -8,15 +8,20 @@ import {useProfileStore} from 'src/nostr/store/ProfileStore' import {useContactStore} from 'src/nostr/store/ContactStore' import {useSettingsStore} from 'stores/Settings' import {useStatStore} from 'src/nostr/store/StatStore' +import {Observable} from 'src/nostr/utils' +import {CloseAfter} from 'src/nostr/Relay' +import DateUtils from 'src/utils/DateUtils' -export const Feeds = { - GLOBAL: { - name: 'global', - filters: { - kinds: [EventKind.NOTE], // TODO Deletions - }, - initialFetchSize: 100, - }, +class Stream extends Observable { + constructor(sub) { + super() + this.sub = sub + sub.on('close', this.emit.bind(this, 'close')) + } + + close(relay = null) { + this.sub.close(relay) + } } const eventQueue = (client, subId) => new FetchQueue( @@ -76,10 +81,10 @@ export const useNostrStore = defineStore('nostr', { if (relay?.url) { if (this.seenBy[event.id]) { - this.seenBy[event.id][relay.url] = Date.now() + this.seenBy[event.id][relay.url] = DateUtils.now() } else { this.seenBy[event.id] = { - [relay.url]: Date.now() + [relay.url]: DateUtils.now() } const stats = useStatStore() @@ -107,7 +112,8 @@ export const useNostrStore = defineStore('nostr', { case EventKind.DELETE: break case EventKind.SHARE: - break + // TODO + return event case EventKind.REACTION: { const notes = useNoteStore() return notes.addEvent(event) @@ -162,7 +168,7 @@ export const useNostrStore = defineStore('nostr', { }, fetchNotesByAuthor(pubkey, limit = 100) { - return this.fetchMultiple( + return this.fetch( { kinds: [EventKind.NOTE], authors: [pubkey], @@ -187,7 +193,7 @@ export const useNostrStore = defineStore('nostr', { fetchFollowers(pubkey, opts = {}) { const limit = opts.limit || 500 - return this.fetchMultiple( + return this.fetch( { kinds: [EventKind.CONTACT], '#p': [pubkey], @@ -204,7 +210,7 @@ export const useNostrStore = defineStore('nostr', { }, fetchReactionsTo(id, limit = 500) { - return this.fetchMultiple( + return this.fetch( { kinds: [EventKind.REACTION], '#e': [id], @@ -221,7 +227,7 @@ export const useNostrStore = defineStore('nostr', { }, fetchReactionsByAuthor(pubkey, limit = 500) { - return this.fetchMultiple( + return this.fetch( { kinds: [EventKind.REACTION], authors: [pubkey], @@ -230,47 +236,98 @@ export const useNostrStore = defineStore('nostr', { ) }, - async fetchMultiple(filters, limit = 100, timeout = 5000) { - const events = await this.client.fetchMultiple(filters, limit, timeout) - return events.map(event => this.addEvent(event)) + async fetch(filters, opts = {}) { + return new Promise(resolve => { + const events = {} + const sub = this.client.subscribe(filters, opts.subId, CloseAfter.EOSE) + + const timer = setTimeout(() => { + const values = Object.values(events) + console.log(`[TIMEOUT] fetch ${sub.subId} (${values.length})`, filters) + sub.close() + resolve(values) + }, opts.timeout || 4000) + + sub.on('end', () => { + const values = Object.values(events) + console.log(`[COMPLETE] fetch ${sub.subId} (${values.length})`, filters) + sub.close() + clearTimeout(timer) + resolve(values) + }) + sub.on('close', () => { + clearTimeout(timer) + resolve(Object.values(events)) + }) + sub.on('event', (event, relay) => { + const object = this.addEvent(event, relay) + if (!object) { + console.warn('Discarding event', event) + return + } + events[event.id] = object + }) + }) }, - streamThread(rootId, eventCallback, initialFetchCompleteCallback) { - return this.streamEvents( + stream(filters, opts = {}) { + let objects = {} + const sub = this.client.subscribe(filters, opts.subId) + const stream = new Stream(sub) + + const timer = setTimeout(() => { + const values = Object.values(objects) + console.log(`[TIMEOUT] stream ${sub.subId} (${values.length})`, filters) + stream.emit('init', values) + objects = null + }, opts.timeout || 5000) + + sub.on('end', () => { + clearTimeout(timer) + const values = Object.values(objects) + console.log(`[COMPLETE] stream ${sub.subId} (${values.length})`, filters) + stream.emit('init', values) + objects = null + }) + sub.on('event', (event, relay) => { + const known = this.hasEvent(event.id) + const object = this.addEvent(event, relay) + if (!object) { + console.warn('Discarding event', event) + return + } + if (known) return + + if (!objects) { + stream.emit('update', object, relay, stream) + } else { + objects[event.id] = object + } + }) + + return stream + }, + + streamThread(rootId) { + return this.stream( { - kinds: [EventKind.NOTE], + kinds: [EventKind.NOTE, EventKind.REACTION, EventKind.SHARE], '#e': [rootId], + limit: 500, }, - 500, - eventCallback, - initialFetchCompleteCallback, { subId: `thread:${rootId}`, } ) }, - streamFeed(feed, eventCallback, initialFetchCompleteCallback) { - return this.streamEvents( - feed.filters, - feed.initialFetchSize, - eventCallback, - initialFetchCompleteCallback, - { - subId: feed.name, - } - ) - }, - - streamNotifications(pubkey, eventCallback, initialFetchCompleteCallback) { - return this.streamEvents( + streamNotifications(pubkey) { + return this.stream( { kinds: [EventKind.NOTE, EventKind.REACTION], // TODO SHARE, CONTACT '#p': [pubkey], + limit: 50, }, - 50, - eventCallback, - initialFetchCompleteCallback, { subId: `notifications:${pubkey}`, } @@ -315,39 +372,5 @@ export const useNostrStore = defineStore('nostr', { this.client.unsubscribe(subId) } }, - - streamEvents(filters, initialFetchSize, eventCallback, initialFetchCompleteCallback, opts) { - const filtersWithLimit = Object.assign({}, filters, {limit: initialFetchSize}) - - let initialFetchComplete = false - - const sub = this.client.subscribe(filtersWithLimit, opts.subId || null) - const timer = setTimeout(() => { - console.log(`[TIMEOUT] ${sub.subId}, intialFetchComplete=${initialFetchComplete}`) - if (!initialFetchComplete) { - initialFetchComplete = true - if (initialFetchCompleteCallback) initialFetchCompleteCallback() - } - }, opts.timeout || 3000) - 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) - }) - sub.on('eose', (relay, subId) => { - console.log(`[EOSE] ${subId} ${relay} ${this.client.connectedRelays().length}`) - }) - sub.on('complete', () => { - console.log(`[COMPLETE] ${sub.subId}, intialFetchComplete=${initialFetchComplete}`) - if (!initialFetchComplete) { - initialFetchComplete = true - clearTimeout(timer) - if (initialFetchCompleteCallback) initialFetchCompleteCallback() - } - }) - - return sub - } }, }) diff --git a/src/nostr/RelayPool.js b/src/nostr/RelayPool.js index c7ec167..880608a 100644 --- a/src/nostr/RelayPool.js +++ b/src/nostr/RelayPool.js @@ -45,7 +45,7 @@ class MultiSubscription extends Observable { if (!sub.eoseSeen) { sub.eoseSeen = true if (Object.values(this.subs).every(sub => sub.eoseSeen)) { - this.emit('complete', this.subId) + this.emit('end', this.subId) } } } @@ -103,6 +103,7 @@ export default class ReplayPool extends Observable { subscribe(filters, subId = null, closeAfter = CloseAfter.NEVER) { if (!subId) subId = `sub${this.nextSubId++}` + console.log(`[SUBSCRIBE] ${subId}`, filters) const sub = new MultiSubscription(subId, []) sub.on('close', this.unsubscribe.bind(this, subId)) @@ -123,6 +124,7 @@ export default class ReplayPool extends Observable { unsubscribe(subId) { const sub = this.subs[subId] if (!sub) return + console.log(`[UNSUBSCRIBE] ${subId}`) sub.sub.close() delete this.subs[subId] } diff --git a/src/nostr/model/Event.js b/src/nostr/model/Event.js index 9ac2980..302c1eb 100644 --- a/src/nostr/model/Event.js +++ b/src/nostr/model/Event.js @@ -1,4 +1,5 @@ import {getEventHash} from 'nostr-tools' +import DateUtils from 'src/utils/DateUtils' export const EventKind = { METADATA: 0, @@ -71,7 +72,7 @@ export default class Event { } static fresh(opts) { - opts.createdAt = opts.createdAt || Math.floor(Date.now() / 1000) + opts.createdAt = opts.createdAt || DateUtils.now() return new Event(opts) } diff --git a/src/nostr/store/ContactStore.js b/src/nostr/store/ContactStore.js index 9ac12a7..307950f 100644 --- a/src/nostr/store/ContactStore.js +++ b/src/nostr/store/ContactStore.js @@ -20,7 +20,7 @@ export const useContactStore = defineStore('contact', { const existingContacts = this.contacts[event.pubkey] if (existingContacts && existingContacts.lastUpdatedAt >= event.createdAt) { - return + return existingContacts } const newContacts = [] diff --git a/src/pages/Feed.vue b/src/pages/Feed.vue index 80e8820..d18e77c 100644 --- a/src/pages/Feed.vue +++ b/src/pages/Feed.vue @@ -31,11 +31,10 @@
-
@@ -45,11 +44,11 @@ - - - - - +
@@ -60,11 +59,23 @@ import PageHeader from 'components/PageHeader.vue' import PostEditor from 'components/CreatePost/PostEditor.vue' import Thread from 'components/Post/Thread.vue' import BaseIcon from 'components/BaseIcon/index.vue' -import ButtonLoadMore from 'components/ButtonLoadMore.vue' +import AsyncLoadButton from 'components/AsyncLoadButton.vue' import ListPlaceholder from 'components/ListPlaceholder.vue' import {useAppStore} from 'stores/App' -import {useNostrStore, Feeds} from 'src/nostr/NostrStore' +import {useNostrStore} from 'src/nostr/NostrStore' import Defer from 'src/utils/Defer' +import {EventKind} from 'src/nostr/model/Event' +import DateUtils from 'src/utils/DateUtils' + +const Feeds = { + global: { + name: 'global', + filters: { + kinds: [EventKind.NOTE], // TODO Deletions + limit: 20, + }, + }, +} const feedOrder = (a, b) => b[0].createdAt - a[0].createdAt @@ -75,10 +86,10 @@ export default defineComponent({ PostEditor, Thread, BaseIcon, - ButtonLoadMore, + AsyncLoadButton, ListPlaceholder, }, - mixins: [Defer()], + mixins: [Defer(2000)], setup() { return { app: useAppStore(), @@ -92,7 +103,6 @@ export default defineComponent({ selectedFeed: 'global', loading: true, recentlyLoaded: true, - sub: null, } }, computed: { @@ -114,57 +124,73 @@ export default defineComponent({ initFeed(feedId) { if (this.feeds[feedId]) return + const filters = Feeds[feedId].filters + const stream = this.nostr.stream( + filters, + {subId: `feed:${feedId}`} + ) + stream.on('init', events => { + const items = events.map(event => [event]) // TODO Single element thread + items.sort(feedOrder) + this.feeds[feedId].items = items.slice(0, filters.limit) + this.loading = false + + // Wait a bit before showing the first unreads + setTimeout(() => this.recentlyLoaded = false, 5000) + }) + stream.on('update', event => { + this.feeds[feedId].unreads.push([event]) // TODO Single element thread + }) + this.feeds[feedId] = { items: [], unreads: [], + stream, } - - let initialFetchComplete = false - let initialItems = [] - - console.log(`subscribing to feed ${feedId}`, this.feeds[feedId]) - - this.sub = this.nostr.streamFeed( - Feeds[feedId.toUpperCase()], - event => { - const target = initialFetchComplete - ? this.feeds[feedId].unreads - : initialItems - target.push([event]) // FIXME Single element thread - }, - () => { - initialItems.sort(feedOrder) - this.feeds[feedId].items = initialItems.slice(0, 50) - initialFetchComplete = true - this.loading = false - - // Wait a bit before showing the first unreads - setTimeout(() => this.recentlyLoaded = false, 5000) - } - ) }, switchFeed(feedId) { this.initFeed(feedId) this.selectedFeed = feedId }, loadUnreads() { - this.loading = true + // TODO Deduplicate feed items const items = this.feedUnreads.concat(this.feedItems) - items.sort(feedOrder) + //items.sort(feedOrder) this.activeFeed.items = items this.activeFeed.unreads = [] - this.loading = false // Wait a bit before showing unreads again this.recentlyLoaded = true setTimeout(() => this.recentlyLoaded = false, 5000) + + return true + }, + async loadOlder() { + const until = this.feedItems[this.feedItems.length - 1]?.[0]?.createdAt || DateUtils.now() + console.log('until', new Date(until * 1000)) + const filters = Object.assign({}, Feeds[this.selectedFeed].filters, {until}) + + const older = await this.nostr.fetch(filters, {subId: `feed:${this.selectedFeed}-older`}) + const items = older.map(event => [event]).sort(feedOrder) + + console.log('got items', older) + + console.log('length before', this.activeFeed.items.length) + // TODO Deduplicate feed items + this.activeFeed.items = this.feedItems.concat(items) + + console.log('length after', this.activeFeed.items.length) + + return older }, }, mounted() { this.initFeed(this.selectedFeed) }, unmounted() { - if (this.sub) this.nostr.cancelStream(this.sub) + for (const feed of Object.values(this.feeds)) { + feed.stream.close() + } } }) @@ -179,7 +205,7 @@ export default defineComponent({ border-bottom: $border-dark; min-height: 6px; } - > .load-more:last-child { + > .async-load-button:last-child { border-bottom: 0; } } diff --git a/src/pages/Notifications.vue b/src/pages/Notifications.vue index 8c742b3..f933f57 100644 --- a/src/pages/Notifications.vue +++ b/src/pages/Notifications.vue @@ -32,30 +32,24 @@ export default { data() { return { notifications: [], + stream: null, loading: true, } }, methods: { }, mounted() { - const read = [] - const unread = [] - this.nostr.streamNotifications( - this.app.myPubkey, - event => { - if (event.createdAt >= this.settings.notificationsLastRead) { - unread.push(event) - } else { - read.push(event) - } - }, - () => { - unread.sort(NoteOrder.CREATION_DATE_DESC) - this.notifications = unread - this.loading = false - } - ) - } + this.stream = this.nostr.streamNotifications(this.app.myPubkey) + this.stream.on('init', events => { + events.sort(NoteOrder.CREATION_DATE_DESC) + this.notifications = events + this.loading = false + }) + this.stream.on('update', event => this.notifications.unshift(event)) + }, + unmounted() { + if (this.stream) this.stream.close() + }, } diff --git a/src/pages/Thread.vue b/src/pages/Thread.vue index b982492..c416a78 100644 --- a/src/pages/Thread.vue +++ b/src/pages/Thread.vue @@ -4,6 +4,7 @@
{}, // TODO - this.buildThread.bind(this) - ) + this.stream = this.nostr.streamThread(this.rootId) + this.stream.on('init', this.buildThread.bind(this)) }, - cancelStream() { - if (!this.subId) return - this.nostr.cancelStream(this.subId) - this.subId = null + closeStream() { + if (!this.stream) return + this.stream.close() + this.stream = null }, buildThread() { @@ -235,7 +233,7 @@ export default defineComponent({ }, unmounted() { console.log('unmounted', this.subId) - this.cancelStream() + this.closeStream() this.resizeObserver.disconnect() } }) diff --git a/src/utils/DateUtils.js b/src/utils/DateUtils.js index e747b71..4891ad4 100644 --- a/src/utils/DateUtils.js +++ b/src/utils/DateUtils.js @@ -17,6 +17,10 @@ const MONTHS = [ ] export default class DateUtils { + static now() { + return Math.floor(Date.now() / 1000) + } + static formatDate(timestamp) { const date = new Date(timestamp * 1000) const month = MONTHS[date.getMonth()] // TODO i18n @@ -49,8 +53,7 @@ export default class DateUtils { } static formatFromNowShort(timestamp) { - const now = Date.now() - const diff = Math.round(Math.max(now - (timestamp * 1000), 0) / 1000) + const diff = Math.max(DateUtils.now() - timestamp, 0) const formatDiff = (unit, factor, offset) => Math.max(Math.floor((diff + (unit * offset)) / (unit * factor)), 1) if (diff < 45) return `${formatDiff(1, 1, 0)}s` diff --git a/src/utils/Nip05.js b/src/utils/Nip05.js index ce24867..c522727 100644 --- a/src/utils/Nip05.js +++ b/src/utils/Nip05.js @@ -9,9 +9,10 @@ export default class Nip05 { try { const res = await fetch(url) const json = await res.json() + if (!json || !json.names) return return json.names[user] } catch (e) { - console.warn(`Failed to fetch NIP05 data for ${nip05Id}`, e) + //console.warn(`Failed to fetch NIP05 data for ${nip05Id}`, e) } }