diff --git a/src/components/CreatePost/PostEditor.vue b/src/components/CreatePost/PostEditor.vue index dad551b..a10e135 100644 --- a/src/components/CreatePost/PostEditor.vue +++ b/src/components/CreatePost/PostEditor.vue @@ -114,29 +114,30 @@ export default { }, async publishPost() { this.publishing = true - try { - const event = this.ancestor - ? EventBuilder.reply(this.ancestor, this.app.myPubkey, this.content).build() - : EventBuilder.post(this.app.myPubkey, this.content).build() - if (!await this.app.signEvent(event)) return - this.nostr.publish(event) + const event = this.ancestor + ? EventBuilder.reply(this.ancestor, this.app.myPubkey, this.content).build() + : EventBuilder.post(this.app.myPubkey, this.content).build() + if (!await this.app.signEvent(event)) return + + const numRelays = await this.nostr.publish(event) + if (numRelays) { this.reset() this.$emit('publish', event) // TODO i18n const postType = this.ancestor ? 'Reply' : 'Post' this.$q.notify({ - message: `${postType} published`, + message: `${postType} published to ${numRelays} relays`, color: 'positive', }) - } catch (e) { - console.error('Failed to publish post', e) + } else { this.$q.notify({ message: `Failed to publish post`, color: 'negative' }) } + this.publishing = false }, }, diff --git a/src/components/Message/MessageEditor.vue b/src/components/Message/MessageEditor.vue index 19f6454..35b47ca 100644 --- a/src/components/Message/MessageEditor.vue +++ b/src/components/Message/MessageEditor.vue @@ -94,24 +94,23 @@ export default { }, async publishMessage() { this.publishing = true - try { - const ciphertext = await this.app.encryptMessage(this.recipient, this.content) - if (!ciphertext) return - const event = EventBuilder.message(this.app.myPubkey, this.recipient, ciphertext).build() - if (!await this.app.signEvent(event)) return - this.nostr.publish(event) + const ciphertext = await this.app.encryptMessage(this.recipient, this.content) + if (!ciphertext) return + const event = EventBuilder.message(this.app.myPubkey, this.recipient, ciphertext).build() + if (!await this.app.signEvent(event)) return + + if (await this.nostr.publish(event)) { this.reset() this.$nextTick(this.focus.bind(this)) - this.$emit('publish', event) - } catch (e) { - console.error('Failed to send message', e) + } else { this.$q.notify({ message: `Failed to send message`, color: 'negative' }) } + this.publishing = false }, }, diff --git a/src/components/Post/PostActions.vue b/src/components/Post/PostActions.vue index cb1fe68..6abaaa2 100644 --- a/src/components/Post/PostActions.vue +++ b/src/components/Post/PostActions.vue @@ -76,13 +76,23 @@ export default { async publishLike() { const event = EventBuilder.reaction(this.note, this.app.myPubkey).build() if (!await this.app.signEvent(event)) return - this.nostr.publish(event) + if (!await this.nostr.publish(event)) { + this.$q.notify({ + message: 'Failed to publish reaction', + color: 'negative', + }) + } }, async deleteLike() { const ids = this.ourReactions.map(r => r.id) const event = EventBuilder.delete(this.app.myPubkey, ids).build() if (!await this.app.signEvent(event)) return - this.nostr.publish(event) + if (!await this.nostr.publish(event)) { + this.$q.notify({ + message: 'Failed to delete reaction', + color: 'negative', + }) + } }, }, } diff --git a/src/components/Settings/ProfileSettings.vue b/src/components/Settings/ProfileSettings.vue index 7ade864..172eacb 100644 --- a/src/components/Settings/ProfileSettings.vue +++ b/src/components/Settings/ProfileSettings.vue @@ -78,7 +78,12 @@ export default { } const event = EventBuilder.metadata(this.pubkey, metadata).build() if (!await this.app.signEvent(event)) return - this.nostr.publish(event) + if (!await this.nostr.publish(event)) { + this.$q.notify({ + message: 'Failed to update profile', + color: 'negative' + }) + } }, }, watch: { diff --git a/src/components/SignIn/SignUpForm.vue b/src/components/SignIn/SignUpForm.vue index da1e7a6..d6a19d3 100644 --- a/src/components/SignIn/SignUpForm.vue +++ b/src/components/SignIn/SignUpForm.vue @@ -41,12 +41,17 @@ export default { const event = EventBuilder.metadata(account.pubkey, {name: this.username}).build() await useAppStore().signEvent(event) - useNostrStore().publish(event) - - this.$emit('complete', { - pubkey: account.pubkey, - name: this.username - }) + if (await useNostrStore().publish(event)) { + this.$emit('complete', { + pubkey: account.pubkey, + name: this.username + }) + } else { + this.$q.notify({ + message: 'Failed to create profile', + color: 'negative', + }) + } } }, mounted() { diff --git a/src/components/User/FollowButton.vue b/src/components/User/FollowButton.vue index 9503f84..2f2e266 100644 --- a/src/components/User/FollowButton.vue +++ b/src/components/User/FollowButton.vue @@ -41,7 +41,12 @@ export default { async updateContacts(contacts) { const event = EventBuilder.contacts(this.app.myPubkey, contacts.map(c => c.pubkey)).build() if (!await this.app.signEvent(event)) return - this.nostr.publish(event) + if (!await this.nostr.publish(event)) { + this.$q.notify({ + message: 'Failed to update followers', + color: 'negative', + }) + } }, toggleFollow() { return this.isFollowing diff --git a/src/nostr/FetchQueue.js b/src/nostr/FetchQueue.js index d81f787..e39bec1 100644 --- a/src/nostr/FetchQueue.js +++ b/src/nostr/FetchQueue.js @@ -1,4 +1,4 @@ -import {Observable} from 'src/nostr/utils' +import {Observable} from 'src/nostr/Observable' export default class FetchQueue extends Observable { constructor(client, subId, fnGetId, fnCreateFilter, opts = {}) { diff --git a/src/nostr/NostrStore.js b/src/nostr/NostrStore.js index 4801dda..e0e8ea1 100644 --- a/src/nostr/NostrStore.js +++ b/src/nostr/NostrStore.js @@ -10,7 +10,7 @@ import {useSettingsStore} from 'stores/Settings' import {useStatStore} from 'src/nostr/store/StatStore' import {useAppStore} from 'stores/App' import {useMessageStore} from 'src/nostr/store/MessageStore' -import {Observable} from 'src/nostr/utils' +import {Observable} from 'src/nostr/Observable' import {CloseAfter} from 'src/nostr/Relay' import DateUtils from 'src/utils/DateUtils' @@ -137,10 +137,13 @@ export const useNostrStore = defineStore('nostr', { return !!this.seenBy[id] }, - publish(event) { - // FIXME represent 'local' somehow - this.addEvent(event, {url: ''}) - return this.client.publish(event) + async publish(event) { + const result = await this.client.publish(event) + if (result) { + // FIXME represent 'local' somehow + this.addEvent(event, {url: ''}) + } + return result }, subscribeForUser(pubkey) { diff --git a/src/nostr/Observable.js b/src/nostr/Observable.js new file mode 100644 index 0000000..6f3b770 --- /dev/null +++ b/src/nostr/Observable.js @@ -0,0 +1,43 @@ +export class Observable { + constructor() { + this.listeners = {} + } + + on(event, callback) { + this.addListener(event, {callback, once: false}) + } + + once(event, callback) { + this.addListener(event, {callback, once: true}) + } + + off(event, callback) { + const listeners = this.listeners[event] + if (!listeners) return + const idx = listeners.findIndex(listener => listener.callback === callback) + if (idx >= 0) listeners.splice(idx, 1) + } + + addListener(event, listener) { + if (!this.listeners[event]) { + this.listeners[event] = [listener] + } else { + this.listeners[event].push(listener) + } + } + + emit(event, ...args) { + const listeners = this.listeners[event] + if (!listeners) return + + for (const listener of listeners) { + try { + listener.callback.apply(null, args) + } catch (e) { + console.error(`Exception thrown from '${event}' listener: ${e.message || e}`, e) + } + } + + this.listeners[event] = listeners.filter(listener => !listener.once) + } +} diff --git a/src/nostr/Relay.js b/src/nostr/Relay.js index ec2a5d5..5b02a85 100644 --- a/src/nostr/Relay.js +++ b/src/nostr/Relay.js @@ -1,4 +1,4 @@ -import {Observable} from 'src/nostr/utils' +import {Observable} from 'src/nostr/Observable' import Event from 'src/nostr/model/Event' export class Subscription extends Observable { @@ -75,7 +75,26 @@ export class Relay extends Observable { } publish(event) { - this.socket.send(['EVENT', event]) + return new Promise(resolve => { + if (!this.socket.send(['EVENT', event])) { + return resolve(false) + } + + let timeout + const callback = (eventId, wasSaved) => { + if (eventId === event.id && wasSaved) { + clearTimeout(timeout) + this.off('ok', callback) + resolve(true) + } + } + timeout = setTimeout(() => { + this.off('ok', callback) + resolve(false) + }, 4000) // TODO make this a parameter + + this.on('ok', callback) + }) } subscribe(filters, subId = null, closeAfter = CloseAfter.NEVER) { @@ -176,11 +195,15 @@ class ReconnectingWebSocket extends Observable { this.disconnected = false this.reconnectAfter = this.opts.reconnectAfter this.reconnectTimer = null + + window.addEventListener('online', this.connect.bind(this)) + window.addEventListener('focus', this.connect.bind(this)) } connect() { - if (this.socket) return + if (this.isConnected()) return this.disconnected = false + this.reconnectTimer = null const ws = new WebSocket(this.url) ws.onopen = this.onOpen.bind(this) @@ -192,36 +215,47 @@ class ReconnectingWebSocket extends Observable { disconnect() { this.disconnected = true - if (this.socket) this.socket.close() - this.socket = null + this.close() } reconnect() { if (this.disconnected || this.reconnectTimer) return + console.log(`[RELAY] Scheduling reconnect to ${this.url} in ${this.reconnectAfter}ms`) this.reconnectTimer = setTimeout( () => { - this.connect() + console.log(`[RELAY] Reconnecting to ${this.url} now`) this.reconnectTimer = null + this.connect() }, this.reconnectAfter ) - this.reconnectAfter *= 2 + this.reconnectAfter = Math.min(this.reconnectAfter *= 2, 1000 * 60 * 5) } isConnected() { return this.socket && this.socket.readyState === WebSocket.OPEN } + close() { + if (this.socket) this.socket.close() + this.socket = null + } + send(message) { // TODO Wait for connected? if (!this.isConnected()) { console.warn(`Not connected to ${this.url} (currently ${this.socket?.readyState})`) - return + return false } - this.socket.send(JSON.stringify(message)) + try { + this.socket.send(JSON.stringify(message)) + return true + } catch (e) { + return false + } } onOpen() { @@ -230,15 +264,19 @@ class ReconnectingWebSocket extends Observable { } onClose() { + this.close() this.emit('close', this) if (this.opts.reconnect) this.reconnect() } onError(error) { console.log(`Socket error from relay ${this.url}`, error) - this.emit('error', error, this) - if (this.opts.reconnect) this.reconnect() + + if (!this.isConnected()) { + this.close() + if (this.opts.reconnect) this.reconnect() + } } onMessage(message) { diff --git a/src/nostr/RelayPool.js b/src/nostr/RelayPool.js index 880608a..cd87d8d 100644 --- a/src/nostr/RelayPool.js +++ b/src/nostr/RelayPool.js @@ -1,5 +1,5 @@ import {CloseAfter, Relay} from 'src/nostr/Relay' -import {Observable} from 'src/nostr/utils' +import {Observable} from 'src/nostr/Observable' class MultiSubscription extends Observable { constructor(subId, subs) { @@ -95,10 +95,17 @@ export default class ReplayPool extends Observable { delete this.relays[url] } - publish(event) { + async publish(event) { + const promises = [] for (const relay of this.connectedRelays()) { - relay.publish(event) + promises.push(relay.publish(event)) } + return Promise.all(promises) + .then(results => results.filter(res => res).length) + .catch(e => { + console.error('Error while publishing', e) + return 0 + }) } subscribe(filters, subId = null, closeAfter = CloseAfter.NEVER) { diff --git a/src/nostr/utils.js b/src/nostr/utils.js deleted file mode 100644 index 18ccce5..0000000 --- a/src/nostr/utils.js +++ /dev/null @@ -1,26 +0,0 @@ -export class Observable { - constructor() { - this.listeners = {} - } - - on(event, callback) { - if (!this.listeners[event]) { - this.listeners[event] = [callback] - } else { - this.listeners[event].push(callback) - } - } - - emit(event, ...args) { - const listeners = this.listeners[event] - if (!listeners) return - - for (const listener of listeners) { - try { - listener.apply(null, args) - } catch (e) { - console.error(`Exception thrown from '${event}' listener: ${e.message || e}`, e) - } - } - } -}