From 781948afd603d20555a1191d63d350f72cf9227f Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Wed, 10 Apr 2024 17:59:30 -0500 Subject: [PATCH] Use Relay class from nostr-tools upgrade nostr-tools from 2.1.3 to 2.4.0 improve parsing zaps --- .changeset/ninety-starfishes-teach.md | 5 + package.json | 2 +- src/classes/batch-kind-loader.ts | 27 +-- src/classes/chunked-request.ts | 42 ++-- src/classes/nostr-multi-subscription.ts | 104 ++++----- src/classes/nostr-publish-action.ts | 36 ++- src/classes/nostr-request.ts | 95 -------- src/classes/nostr-subscription.ts | 41 ++-- src/classes/notifications.ts | 2 +- src/classes/relay-pool.ts | 20 +- src/classes/relay.ts | 205 ------------------ src/classes/timeline-loader.ts | 7 +- src/components/embed-types/image.tsx | 3 +- src/components/publish-details.tsx | 6 +- src/components/publish-log.tsx | 6 +- .../relay-management-drawer/index.tsx | 17 +- src/components/relay-status.tsx | 20 +- src/helpers/nostr/zaps.ts | 26 ++- src/helpers/relay.ts | 81 ++++++- src/hooks/use-event-zaps.ts | 7 +- src/hooks/use-stream-goal.ts | 11 +- src/providers/global/publish-provider.tsx | 4 +- src/services/dns-identity.ts | 2 +- src/services/event-exists.ts | 31 +-- src/services/event-reactions.ts | 8 +- src/services/event-relays.ts | 14 +- src/services/event-zaps.ts | 8 +- src/services/nostr-connect.ts | 26 +-- src/services/relay-stats.ts | 17 +- src/services/replaceable-events.ts | 3 +- src/services/single-event.ts | 8 +- .../components/notification-item.tsx | 8 +- src/views/relays/app/relay-control.tsx | 14 +- src/views/relays/mailboxes/index.tsx | 2 +- src/views/relays/relay/relay-details.tsx | 23 +- src/views/streams/components/top-zappers.tsx | 12 +- .../stream/stream-chat/zap-message.tsx | 8 +- src/views/tools/network-dm-graph.tsx | 12 +- src/views/user/zaps.tsx | 21 +- yarn.lock | 14 ++ 40 files changed, 379 insertions(+), 619 deletions(-) create mode 100644 .changeset/ninety-starfishes-teach.md delete mode 100644 src/classes/nostr-request.ts diff --git a/.changeset/ninety-starfishes-teach.md b/.changeset/ninety-starfishes-teach.md new file mode 100644 index 000000000..990954417 --- /dev/null +++ b/.changeset/ninety-starfishes-teach.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Use Relay class from nostr-tools diff --git a/package.json b/package.json index 1359d6b32..de09c6b5e 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "nanoid": "^5.0.4", "ngeohash": "^0.6.3", "nostr-idb": "^2.1.1", - "nostr-tools": "^2.1.3", + "nostr-tools": "^2.4.0", "react": "^18.2.0", "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", diff --git a/src/classes/batch-kind-loader.ts b/src/classes/batch-kind-loader.ts index 52413671f..7fb1a2d4a 100644 --- a/src/classes/batch-kind-loader.ts +++ b/src/classes/batch-kind-loader.ts @@ -1,9 +1,8 @@ import dayjs from "dayjs"; -import { Filter, NostrEvent } from "nostr-tools"; +import { Filter, NostrEvent, Relay, Subscription } from "nostr-tools"; import _throttle from "lodash.throttle"; import debug, { Debugger } from "debug"; -import NostrSubscription from "./nostr-subscription"; import EventStore from "./event-store"; import { getEventCoordinate } from "../helpers/nostr/event"; @@ -15,20 +14,17 @@ const RELAY_REQUEST_BATCH_TIME = 500; /** This class is ued to batch requests by kind to a single relay */ export default class BatchKindLoader { - private subscription: NostrSubscription; + private subscription: Subscription | null = null; events = new EventStore(); + relay: Relay; private requestNext = new Set(); private requested = new Map(); log: Debugger; - constructor(relay: string, log?: Debugger) { - this.subscription = new NostrSubscription(relay, undefined, `replaceable-event-loader`); - - this.subscription.onEvent.subscribe(this.handleEvent.bind(this)); - this.subscription.onEOSE.subscribe(this.handleEOSE.bind(this)); - + constructor(relay: Relay, log?: Debugger) { + this.relay = relay; this.log = log || debug("RelayBatchLoader"); } @@ -105,12 +101,17 @@ export default class BatchKindLoader { .map((kind: string) => `kind ${kind}: ${filters[parseInt(kind)].authors?.length}`) .join(", "), ); - this.subscription.setFilters(query); - if (this.subscription.state !== NostrSubscription.OPEN) { - this.subscription.open(); + if (!this.subscription || this.subscription.closed) { + this.subscription = this.relay.subscribe(query, { + onevent: (event) => this.handleEvent(event), + oneose: () => this.handleEOSE(), + }); + } else { + this.subscription.filters = query; + this.subscription.fire(); } - } else if (this.subscription.state === NostrSubscription.OPEN) { + } else if (this.subscription && !this.subscription.closed) { this.subscription.close(); } } diff --git a/src/classes/chunked-request.ts b/src/classes/chunked-request.ts index 7bf7ee360..496e35981 100644 --- a/src/classes/chunked-request.ts +++ b/src/classes/chunked-request.ts @@ -1,21 +1,21 @@ import { Debugger } from "debug"; -import { Filter, NostrEvent, matchFilters } from "nostr-tools"; +import { Filter, NostrEvent, Relay, matchFilters } from "nostr-tools"; import _throttle from "lodash.throttle"; -import NostrRequest from "./nostr-request"; import Subject from "./subject"; import { logger } from "../helpers/debug"; import EventStore from "./event-store"; import deleteEventService from "../services/delete-events"; import { mergeFilter } from "../helpers/nostr/filter"; import { isATag, isETag } from "../types/nostr-event"; +import relayPoolService from "../services/relay-pool"; const DEFAULT_CHUNK_SIZE = 100; export type EventFilter = (event: NostrEvent, store: EventStore) => boolean; export default class ChunkedRequest { - relay: string; + relay: Relay; filters: Filter[]; chunkSize = DEFAULT_CHUNK_SIZE; private log: Debugger; @@ -28,12 +28,12 @@ export default class ChunkedRequest { onChunkFinish = new Subject(); - constructor(relay: string, filters: Filter[], log?: Debugger) { + constructor(relay: Relay, filters: Filter[], log?: Debugger) { this.relay = relay; this.filters = filters; - this.log = log || logger.extend(relay); - this.events = new EventStore(relay); + this.log = log || logger.extend(relay.url); + this.events = new EventStore(relay.url); // TODO: find a better place for this this.subs.push(deleteEventService.stream.subscribe((e) => this.handleDeleteEvent(e))); @@ -47,23 +47,25 @@ export default class ChunkedRequest { filters = mergeFilter(filters, { until: oldestEvent.created_at - 1 }); } - const request = new NostrRequest([this.relay]); + relayPoolService.addClaim(this.relay, this); let gotEvents = 0; - request.onEvent.subscribe((e) => { - this.handleEvent(e); - gotEvents++; + const sub = this.relay.subscribe(filters, { + onevent: (event) => { + this.handleEvent(event); + gotEvents++; + }, + oneose: () => { + this.loading = false; + if (gotEvents === 0) { + this.complete = true; + this.log("Complete"); + } else this.log(`Got ${gotEvents} events`); + this.onChunkFinish.next(gotEvents); + sub.close(); + relayPoolService.removeClaim(this.relay, this); + }, }); - request.onComplete.then(() => { - this.loading = false; - if (gotEvents === 0) { - this.complete = true; - this.log("Complete"); - } else this.log(`Got ${gotEvents} events`); - this.onChunkFinish.next(gotEvents); - }); - - request.start(filters); } private handleEvent(event: NostrEvent) { diff --git a/src/classes/nostr-multi-subscription.ts b/src/classes/nostr-multi-subscription.ts index 5ee74d304..b361b65ba 100644 --- a/src/classes/nostr-multi-subscription.ts +++ b/src/classes/nostr-multi-subscription.ts @@ -1,12 +1,11 @@ import { nanoid } from "nanoid"; import { NostrEvent } from "../types/nostr-event"; -import { NostrRequestFilter, RelayQueryMap } from "../types/nostr-relay"; -import Relay, { IncomingEvent, OutgoingRequest } from "./relay"; +import { RelayQueryMap } from "../types/nostr-relay"; import relayPoolService from "../services/relay-pool"; import { isFilterEqual, isQueryMapEqual } from "../helpers/nostr/filter"; import ControlledObservable from "./controlled-observable"; -import SuperMap from "./super-map"; +import { Relay, Subscription } from "nostr-tools"; export default class NostrMultiSubscription { static INIT = "initial"; @@ -18,6 +17,8 @@ export default class NostrMultiSubscription { queryMap: RelayQueryMap = {}; relays: Relay[] = []; + subscriptions = new Map(); + state = NostrMultiSubscription.INIT; onEvent = new ControlledObservable(); seenEvents = new Set(); @@ -26,38 +27,17 @@ export default class NostrMultiSubscription { this.id = nanoid(); this.name = name; } - private handleMessage(incomingEvent: IncomingEvent) { - if ( - this.state === NostrMultiSubscription.OPEN && - incomingEvent[1] === this.id && - !this.seenEvents.has(incomingEvent[2].id) - ) { - this.onEvent.next(incomingEvent[2]); - this.seenEvents.add(incomingEvent[2].id); - } + private handleEvent(event: NostrEvent) { + if (this.seenEvents.has(event.id)) return; + this.onEvent.next(event); + this.seenEvents.add(event.id); } - private relaySubs = new SuperMap(() => []); - /** listen for event and open events from relays */ - private connectToRelay(relay: Relay) { - const subs = this.relaySubs.get(relay); - subs.push(relay.onEvent.subscribe(this.handleMessage.bind(this))); - subs.push(relay.onOpen.subscribe(this.handleRelayConnect.bind(this))); - subs.push(relay.onClose.subscribe(this.handleRelayDisconnect.bind(this))); + private handleAddRelay(relay: Relay) { relayPoolService.addClaim(relay.url, this); } - /** stop listing to events from relays */ - private disconnectFromRelay(relay: Relay) { - const subs = this.relaySubs.get(relay); - for (const sub of subs) sub.unsubscribe(); - this.relaySubs.delete(relay); + private handleRemoveRelay(relay: Relay) { relayPoolService.removeClaim(relay.url, this); - - // if the subscription is open and had sent a request to the relay - if (this.state === NostrMultiSubscription.OPEN && this.relayQueries.has(relay)) { - relay.send(["CLOSE", this.id]); - } - this.relayQueries.delete(relay); } setQueryMap(queryMap: RelayQueryMap) { @@ -70,7 +50,7 @@ export default class NostrMultiSubscription { // add relay const relay = relayPoolService.requestRelay(url); this.relays.push(relay); - this.connectToRelay(relay); + this.handleAddRelay(relay); } } for (const url of Object.keys(this.queryMap)) { @@ -78,41 +58,51 @@ export default class NostrMultiSubscription { const relay = this.relays.find((r) => r.url === url); if (!relay) continue; this.relays = this.relays.filter((r) => r !== relay); - this.disconnectFromRelay(relay); + this.handleRemoveRelay(relay); } } this.queryMap = queryMap; - this.updateRelayQueries(); + this.updateSubscriptions(); } - private relayQueries = new WeakMap(); - private updateRelayQueries() { - if (this.state !== NostrMultiSubscription.OPEN) return; + private updateSubscriptions() { + // close all subscriptions if not open + if (this.state !== NostrMultiSubscription.OPEN) { + for (const [relay, subscription] of this.subscriptions) { + subscription.close(); + } + this.subscriptions.clear(); + return; + } + // else open and update subscriptions for (const relay of this.relays) { - const filter = this.queryMap[relay.url]; - const message: OutgoingRequest = Array.isArray(filter) ? ["REQ", this.id, ...filter] : ["REQ", this.id, filter]; + const filters = this.queryMap[relay.url]; - const currentFilter = this.relayQueries.get(relay); - if (!currentFilter || !isFilterEqual(currentFilter, filter)) { - this.relayQueries.set(relay, filter); - relay.send(message); + let subscription = this.subscriptions.get(relay); + if (!subscription || !isFilterEqual(subscription.filters, filters)) { + if (subscription) { + subscription.filters = filters; + subscription.fire(); + } else { + subscription = relay.subscribe(filters, { + onevent: (event) => this.handleEvent(event), + onclose: () => { + if (this.subscriptions.get(relay) === subscription) { + this.subscriptions.delete(relay); + } + }, + }); + this.subscriptions.set(relay, subscription); + } } } } - private handleRelayConnect(relay: Relay) { - this.updateRelayQueries(); - } - private handleRelayDisconnect(relay: Relay) { - this.relayQueries.delete(relay); - } - sendAll(event: NostrEvent) { - for (const relay of this.relays) { - relay.send(["EVENT", event]); - } + publish(event: NostrEvent) { + return Promise.all(this.relays.map((r) => r.publish(event))); } open() { @@ -120,14 +110,14 @@ export default class NostrMultiSubscription { this.state = NostrMultiSubscription.OPEN; // reconnect to all relays - for (const relay of this.relays) this.connectToRelay(relay); + for (const relay of this.relays) this.handleAddRelay(relay); // send queries - this.updateRelayQueries(); + this.updateSubscriptions(); return this; } - waitForConnection(): Promise { - return Promise.all(this.relays.map((r) => r.waitForConnection())).then((v) => void 0); + waitForAllConnection(): Promise { + return Promise.all(this.relays.filter((r) => !r.connected).map((r) => r.connect())).then((v) => void 0); } close() { if (this.state !== NostrMultiSubscription.OPEN) return this; @@ -135,7 +125,7 @@ export default class NostrMultiSubscription { // forget all seen events this.forgetEvents(); // unsubscribe from relay messages - for (const relay of this.relays) this.disconnectFromRelay(relay); + for (const relay of this.relays) this.handleRemoveRelay(relay); // set state this.state = NostrMultiSubscription.CLOSED; diff --git a/src/classes/nostr-publish-action.ts b/src/classes/nostr-publish-action.ts index fdcff4db2..4b7ccf863 100644 --- a/src/classes/nostr-publish-action.ts +++ b/src/classes/nostr-publish-action.ts @@ -1,12 +1,12 @@ import { nanoid } from "nanoid"; -import { NostrEvent } from "nostr-tools"; +import { NostrEvent, Relay } from "nostr-tools"; import relayPoolService from "../services/relay-pool"; import createDefer from "./deferred"; -import Relay, { IncomingCommandResult } from "./relay"; import { PersistentSubject } from "./subject"; import ControlledObservable from "./controlled-observable"; -import SuperMap from "./super-map"; + +type Result = { relay: Relay; success: boolean; message: string }; export default class NostrPublishAction { id = nanoid(); @@ -14,13 +14,12 @@ export default class NostrPublishAction { relays: string[]; event: NostrEvent; - results = new PersistentSubject<{ relay: Relay; result: IncomingCommandResult }[]>([]); + results = new PersistentSubject([]); - onResult = new ControlledObservable<{ relay: Relay; result: IncomingCommandResult }>(); - onComplete = createDefer<{ relay: Relay; result: IncomingCommandResult }[]>(); + onResult = new ControlledObservable(); + onComplete = createDefer(); private remaining = new Set(); - private relayResultSubs = new SuperMap(() => []); constructor(label: string, relays: Iterable, event: NostrEvent, timeout: number = 5000) { this.label = label; @@ -30,31 +29,30 @@ export default class NostrPublishAction { for (const url of relays) { const relay = relayPoolService.requestRelay(url); this.remaining.add(relay); - this.relayResultSubs.get(relay).push( - relay.onCommandResult.subscribe((result) => { - if (result[1] === this.event.id) this.handleResult(result, relay); - }), - ); - relay.send(["EVENT", event]); + relay + .publish(event) + .then((result) => this.handleResult(event.id, true, result, relay)) + .catch((err) => { + if (err instanceof Error) this.handleResult(event.id, false, err.message, relay); + }); } setTimeout(this.handleTimeout.bind(this), timeout); } - private handleResult(result: IncomingCommandResult, relay: Relay) { - this.results.next([...this.results.value, { relay, result }]); - this.onResult.next({ relay, result }); + private handleResult(id: string, success: boolean, message: string, relay: Relay) { + const result: Result = { relay, success, message }; + this.results.next([...this.results.value, result]); + this.onResult.next(result); - this.relayResultSubs.get(relay).forEach((s) => s.unsubscribe()); - this.relayResultSubs.delete(relay); this.remaining.delete(relay); if (this.remaining.size === 0) this.onComplete.resolve(this.results.value); } private handleTimeout() { for (const relay of this.remaining) { - this.handleResult(["OK", this.event.id, false, "Timeout"], relay); + this.handleResult(this.event.id, false, "Timeout", relay); } } } diff --git a/src/classes/nostr-request.ts b/src/classes/nostr-request.ts deleted file mode 100644 index 264d421f3..000000000 --- a/src/classes/nostr-request.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { nanoid } from "nanoid"; -import { Filter, NostrEvent } from "nostr-tools"; - -import relayPoolService from "../services/relay-pool"; -import Relay, { CountResponse, IncomingCount, IncomingEOSE, IncomingEvent } from "./relay"; -import createDefer from "./deferred"; -import ControlledObservable from "./controlled-observable"; -import SuperMap from "./super-map"; - -const REQUEST_DEFAULT_TIMEOUT = 1000 * 5; -export default class NostrRequest { - static IDLE = "idle"; - static RUNNING = "running"; - static COMPLETE = "complete"; - - id = nanoid(); - timeout: number; - relays: Set; - state = NostrRequest.IDLE; - onEvent = new ControlledObservable(); - onCount = new ControlledObservable(); - onComplete = createDefer(); - seenEvents = new Set(); - - private relaySubs: SuperMap = new SuperMap(() => []); - - constructor(relayUrls: Iterable, timeout?: number) { - this.relays = new Set(Array.from(relayUrls).map((url) => relayPoolService.requestRelay(url))); - - for (const relay of this.relays) { - const subs = this.relaySubs.get(relay); - subs.push(relay.onEOSE.subscribe((m) => this.handleEOSE(m, relay))); - subs.push(relay.onEvent.subscribe(this.handleEvent.bind(this))); - subs.push(relay.onCount.subscribe(this.handleCount.bind(this))); - } - - this.timeout = timeout ?? REQUEST_DEFAULT_TIMEOUT; - } - - handleEOSE(message: IncomingEOSE, relay: Relay) { - if (message[1] === this.id) { - this.relays.delete(relay); - relay.send(["CLOSE", this.id]); - - this.relaySubs.get(relay).forEach((sub) => sub.unsubscribe()); - this.relaySubs.delete(relay); - - if (this.relays.size === 0) { - this.state = NostrRequest.COMPLETE; - this.onComplete.resolve(); - } - } - } - handleEvent(message: IncomingEvent) { - if (this.state === NostrRequest.RUNNING && message[1] === this.id && !this.seenEvents.has(message[2].id)) { - this.onEvent.next(message[2]); - this.seenEvents.add(message[2].id); - } - } - handleCount(incomingCount: IncomingCount) { - if (incomingCount[1] === this.id) { - this.onCount.next(incomingCount[2]); - } - } - - start(filter: Filter | Filter[], type: "REQ" | "COUNT" = "REQ") { - if (this.state !== NostrRequest.IDLE) { - throw new Error("cant restart a nostr request"); - } - - this.state = NostrRequest.RUNNING; - for (const relay of this.relays) { - if (Array.isArray(filter)) { - relay.send([type, this.id, ...filter]); - } else relay.send([type, this.id, filter]); - } - - setTimeout(() => this.complete(), this.timeout); - - return this; - } - complete() { - if (this.state === NostrRequest.COMPLETE) return this; - - this.state = NostrRequest.COMPLETE; - for (const relay of this.relays) { - relay.send(["CLOSE", this.id]); - this.relaySubs.get(relay).forEach((sub) => sub.unsubscribe()); - } - this.relaySubs.clear(); - this.onComplete.resolve(); - - return this; - } -} diff --git a/src/classes/nostr-subscription.ts b/src/classes/nostr-subscription.ts index 237a3a90c..a92125211 100644 --- a/src/classes/nostr-subscription.ts +++ b/src/classes/nostr-subscription.ts @@ -1,10 +1,10 @@ import { nanoid } from "nanoid"; -import { Filter, NostrEvent } from "nostr-tools"; +import { Filter, NostrEvent, Relay, Subscription } from "nostr-tools"; -import Relay, { IncomingEOSE, OutgoingMessage } from "./relay"; import relayPoolService from "../services/relay-pool"; import ControlledObservable from "./controlled-observable"; +/** @deprecated use relay.subscribe instead */ export default class NostrSubscription { static INIT = "initial"; static OPEN = "open"; @@ -16,10 +16,10 @@ export default class NostrSubscription { relay: Relay; state = NostrSubscription.INIT; - onEvent = new ControlledObservable(); - onEOSE = new ControlledObservable(); + subscription: Subscription | null = null; - private subs: ZenObservable.Subscription[] = []; + onEvent = new ControlledObservable(); + onEOSE = new ControlledObservable(); constructor(relayUrl: string | URL, filters?: Filter[], name?: string) { this.id = nanoid(); @@ -27,28 +27,13 @@ export default class NostrSubscription { this.name = name; this.relay = relayPoolService.requestRelay(relayUrl); - - this.subs.push( - this.relay.onEvent.subscribe((message) => { - if (this.state === NostrSubscription.OPEN && message[1] === this.id) { - this.onEvent.next(message[2]); - } - }), - ); - this.subs.push( - this.relay.onEOSE.subscribe((eose) => { - if (this.state === NostrSubscription.OPEN && eose[1] === this.id) this.onEOSE.next(eose); - }), - ); } - send(message: OutgoingMessage) { - this.relay.send(message); - } setFilters(filters: Filter[]) { this.filters = filters; - if (this.state === NostrSubscription.OPEN) { - this.send(["REQ", this.id, ...this.filters]); + if (this.state === NostrSubscription.OPEN && this.subscription) { + this.subscription.filters = this.filters; + this.subscription.fire(); } return this; } @@ -58,7 +43,10 @@ export default class NostrSubscription { if (this.state === NostrSubscription.OPEN) return this; this.state = NostrSubscription.OPEN; - this.send(["REQ", this.id, ...this.filters]); + this.subscription = this.relay.subscribe(this.filters, { + onevent: (event) => this.onEvent.next(event), + oneose: () => this.onEOSE.next(Math.random()), + }); relayPoolService.addClaim(this.relay.url, this); @@ -70,13 +58,10 @@ export default class NostrSubscription { // set state this.state = NostrSubscription.CLOSED; // send close message - this.send(["CLOSE", this.id]); + this.subscription?.close(); // unsubscribe from relay messages relayPoolService.removeClaim(this.relay.url, this); - for (const sub of this.subs) sub.unsubscribe(); - this.subs = []; - return this; } } diff --git a/src/classes/notifications.ts b/src/classes/notifications.ts index 61bc97c63..9b0c1a50f 100644 --- a/src/classes/notifications.ts +++ b/src/classes/notifications.ts @@ -134,7 +134,7 @@ export default class AccountNotifications { break; } case NotificationType.Zap: - const parsed = getParsedZap(e); + const parsed = getParsedZap(e, true, true); if (parsed instanceof Error) break; if (!parsed.payment.amount) break; timeline.push(e); diff --git a/src/classes/relay-pool.ts b/src/classes/relay-pool.ts index 0673ea171..3e78f95ab 100644 --- a/src/classes/relay-pool.ts +++ b/src/classes/relay-pool.ts @@ -1,7 +1,7 @@ +import { Relay } from "nostr-tools"; import { logger } from "../helpers/debug"; import { validateRelayURL } from "../helpers/relay"; import { offlineMode } from "../services/offline-mode"; -import Relay from "./relay"; import Subject from "./subject"; export default class RelayPool { @@ -34,9 +34,9 @@ export default class RelayPool { } const relay = this.relays.get(key) as Relay; - if (connect && !relay.okay) { + if (connect && !relay.connected) { try { - relay.open(); + relay.connect(); } catch (e) { this.log(`Failed to connect to ${relay.url}`); this.log(e); @@ -58,9 +58,9 @@ export default class RelayPool { for (const [url, relay] of this.relays.entries()) { const claims = this.getRelayClaims(url).size; - if (!relay.okay && claims > 0) { + if (!relay.connected && claims > 0) { try { - relay.open(); + relay.connect(); } catch (e) { this.log(`Failed to connect to ${relay.url}`); this.log(e); @@ -69,14 +69,12 @@ export default class RelayPool { } } - addClaim(url: string | URL, id: any) { - url = validateRelayURL(url); - const key = url.toString(); + addClaim(relay: string | URL | Relay, id: any) { + const key = relay instanceof Relay ? relay.url : validateRelayURL(relay).toString(); this.getRelayClaims(key).add(id); } - removeClaim(url: string | URL, id: any) { - url = validateRelayURL(url); - const key = url.toString(); + removeClaim(relay: string | URL | Relay, id: any) { + const key = relay instanceof Relay ? relay.url : validateRelayURL(relay).toString(); this.getRelayClaims(key).delete(id); } diff --git a/src/classes/relay.ts b/src/classes/relay.ts index b2ce6d015..98cd89bc5 100644 --- a/src/classes/relay.ts +++ b/src/classes/relay.ts @@ -1,211 +1,6 @@ -import { Filter, NostrEvent } from "nostr-tools"; -import { offlineMode } from "../services/offline-mode"; -import relayScoreboardService from "../services/relay-scoreboard"; -import ControlledObservable from "./controlled-observable"; -import createDefer, { Deferred } from "./deferred"; -import { PersistentSubject } from "./subject"; - -export type CountResponse = { - count: number; - approximate?: boolean; -}; - -export type IncomingEvent = ["EVENT", string, NostrEvent]; -export type IncomingNotice = ["NOTICE", string]; -export type IncomingCount = ["COUNT", string, CountResponse]; -export type IncomingEOSE = ["EOSE", string]; -export type IncomingCommandResult = ["OK", string, boolean] | ["OK", string, boolean, string]; -export type IncomingMessage = IncomingEvent | IncomingNotice | IncomingCount | IncomingEOSE | IncomingCommandResult; - -export type OutgoingEvent = ["EVENT", NostrEvent]; -export type OutgoingRequest = ["REQ", string, ...Filter[]]; -export type OutgoingCount = ["COUNT", string, ...Filter[]]; -export type OutgoingClose = ["CLOSE", string]; -export type OutgoingMessage = OutgoingEvent | OutgoingRequest | OutgoingClose | OutgoingCount; - export enum RelayMode { NONE = 0, READ = 1, WRITE = 2, ALL = 1 | 2, } - -const CONNECTION_TIMEOUT = 1000 * 30; - -export default class Relay { - url: string; - ws?: WebSocket; - status = new PersistentSubject(WebSocket.CLOSED); - onOpen = new ControlledObservable(); - onClose = new ControlledObservable(); - - onEvent = new ControlledObservable(); - onNotice = new ControlledObservable(); - onCount = new ControlledObservable(); - onEOSE = new ControlledObservable(); - onCommandResult = new ControlledObservable(); - - private connectionPromises: Deferred[] = []; - - private connectionTimer?: () => void; - private ejectTimer?: () => void; - private intentionalClose = false; - private subscriptionResTimer = new Map void>(); - private queue: OutgoingMessage[] = []; - - constructor(url: string) { - this.url = url; - } - - open() { - if (offlineMode.value) return; - - if (this.okay) return; - this.intentionalClose = false; - this.ws = new WebSocket(this.url); - - this.connectionTimer = relayScoreboardService.relayConnectionTime.get(this.url).createTimer(); - const connectionTimeout: number = window.setTimeout(() => { - // end the connection timer after CONNECTION_TIMEOUT - if (this.connectionTimer) { - this.connectionTimer(); - this.connectionTimer = undefined; - - for (const p of this.connectionPromises) p.reject(); - this.connectionPromises = []; - } - // relayScoreboardService.relayTimeouts.get(this.url).addIncident(); - }, CONNECTION_TIMEOUT); - - // for local dev, cancel timeout if module reloads - if (import.meta.hot) { - import.meta.hot.prune(() => { - window.clearTimeout(connectionTimeout); - this.ws?.close(); - }); - } - - this.ws.onopen = () => { - window.clearTimeout(connectionTimeout); - this.onOpen.next(this); - this.status.next(this.ws!.readyState); - - this.ejectTimer = relayScoreboardService.relayEjectTime.get(this.url).createTimer(); - if (this.connectionTimer) { - this.connectionTimer(); - this.connectionTimer = undefined; - } - - this.sendQueued(); - - for (const p of this.connectionPromises) p.resolve(); - this.connectionPromises = []; - }; - this.ws.onclose = () => { - this.onClose.next(this); - this.status.next(this.ws!.readyState); - - if (!this.intentionalClose && this.ejectTimer) { - this.ejectTimer(); - this.ejectTimer = undefined; - } - }; - this.ws.onmessage = this.handleMessage.bind(this); - } - send(json: OutgoingMessage) { - if (this.connected) { - this.ws?.send(JSON.stringify(json)); - - // record start time - if (json[0] === "REQ" || json[0] === "COUNT") { - this.startSubResTimer(json[1]); - } - } else this.queue.push(json); - } - close() { - this.ws?.close(); - this.intentionalClose = true; - this.subscriptionResTimer.clear(); - } - - waitForConnection(): Promise { - if (this.connected) return Promise.resolve(); - const p = createDefer(); - this.connectionPromises.push(p); - return p; - } - - private startSubResTimer(sub: string) { - this.subscriptionResTimer.set(sub, relayScoreboardService.relayResponseTimes.get(this.url).createTimer()); - } - private endSubResTimer(sub: string) { - const endTimer = this.subscriptionResTimer.get(sub); - if (endTimer) { - endTimer(); - this.subscriptionResTimer.delete(sub); - } - } - - private sendQueued() { - if (this.connected) { - for (const message of this.queue) { - this.send(message); - } - this.queue = []; - } - } - - get okay() { - return this.connected || this.connecting; - } - get connected() { - return this.ws?.readyState === WebSocket.OPEN; - } - get connecting() { - return this.ws?.readyState === WebSocket.CONNECTING; - } - get closing() { - return this.ws?.readyState === WebSocket.CLOSING; - } - get closed() { - return this.ws?.readyState === WebSocket.CLOSED; - } - get state() { - return this.ws?.readyState; - } - - handleMessage(message: MessageEvent) { - if (!message.data) return; - - try { - const data: IncomingMessage = JSON.parse(message.data); - const type = data[0]; - - // all messages must have an argument - if (!data[1]) return; - - switch (type) { - case "EVENT": - this.onEvent.next(data); - this.endSubResTimer(data[1]); - break; - case "NOTICE": - this.onNotice.next(data); - break; - case "COUNT": - this.onCount.next(data); - break; - case "EOSE": - this.onEOSE.next(data); - this.endSubResTimer(data[1]); - break; - case "OK": - this.onCommandResult.next(data); - break; - } - } catch (e) { - console.log(`Relay: Failed to parse massage from ${this.url}`); - console.log(message.data, e); - } - } -} diff --git a/src/classes/timeline-loader.ts b/src/classes/timeline-loader.ts index bb13119d7..a86336a12 100644 --- a/src/classes/timeline-loader.ts +++ b/src/classes/timeline-loader.ts @@ -15,6 +15,7 @@ import { localRelay } from "../services/local-relay"; import { relayRequest } from "../helpers/relay"; import SuperMap from "./super-map"; import ChunkedRequest from "./chunked-request"; +import relayPoolService from "../services/relay-pool"; const BLOCK_SIZE = 100; @@ -124,7 +125,11 @@ export default class TimelineLoader { } if (!this.chunkLoaders.has(relay)) { - const loader = new ChunkedRequest(relay, Array.isArray(filter) ? filter : [filter], this.log.extend(relay)); + const loader = new ChunkedRequest( + relayPoolService.requestRelay(relay), + Array.isArray(filter) ? filter : [filter], + this.log.extend(relay), + ); this.chunkLoaders.set(relay, loader); this.connectToChunkLoader(loader); } diff --git a/src/components/embed-types/image.tsx b/src/components/embed-types/image.tsx index 5d5c55e0a..1551bc2e0 100644 --- a/src/components/embed-types/image.tsx +++ b/src/components/embed-types/image.tsx @@ -1,7 +1,6 @@ import { MouseEventHandler, MutableRefObject, forwardRef, useCallback, useMemo, useRef } from "react"; import { Image, ImageProps, Link, LinkProps } from "@chakra-ui/react"; -import { useTrustContext } from "../../providers/local/trust"; import { EmbedableContent, defaultGetLocation } from "../../helpers/embeds"; import { getMatchLink } from "../../helpers/regexp"; import { useRegisterSlide } from "../lightbox-provider"; @@ -100,7 +99,7 @@ export const GalleryImage = forwardRef( export function ImageGallery({ images, event }: { images: string[]; event?: NostrEvent }) { const photos = useMemo(() => { return images.map((img) => { - const photo: PhotoWithoutSize = { src: img }; + const photo: PhotoWithoutSize = { src: img, key: img }; return photo; }); }, [images]); diff --git a/src/components/publish-details.tsx b/src/components/publish-details.tsx index abd22220b..19d298727 100644 --- a/src/components/publish-details.tsx +++ b/src/components/publish-details.tsx @@ -17,8 +17,8 @@ export function PublishDetails({ pub }: PostResultsProps & Omit - {results.map(({ result, relay }) => ( - + {results.map(({ success, message, relay }) => ( + @@ -27,7 +27,7 @@ export function PublishDetails({ pub }: PostResultsProps & Omit - {result[3] && {result[3]}} + {message && {message}} ))} diff --git a/src/components/publish-log.tsx b/src/components/publish-log.tsx index bc1417780..520685580 100644 --- a/src/components/publish-log.tsx +++ b/src/components/publish-log.tsx @@ -1,3 +1,4 @@ +import { useContext } from "react"; import { Flex, FlexProps, @@ -19,14 +20,13 @@ import NostrPublishAction from "../classes/nostr-publish-action"; import useSubject from "../hooks/use-subject"; import { CheckIcon, ErrorIcon } from "./icons"; import { PublishDetails } from "./publish-details"; -import { useContext } from "react"; import { PublishContext } from "../providers/global/publish-provider"; export function PublishActionStatusTag({ pub, ...props }: { pub: NostrPublishAction } & Omit) { const results = useSubject(pub.results); - const successful = results.filter(({ result }) => result[2]); - const failedWithMessage = results.filter(({ result }) => !result[2] && result[3]); + const successful = results.filter(({ success }) => success); + const failedWithMessage = results.filter(({ success, message }) => !success && !!message); let statusIcon = ; let statusColor: TagProps["colorScheme"] = "blue"; diff --git a/src/components/relay-management-drawer/index.tsx b/src/components/relay-management-drawer/index.tsx index b6e0bc6c1..9214fbd0d 100644 --- a/src/components/relay-management-drawer/index.tsx +++ b/src/components/relay-management-drawer/index.tsx @@ -9,7 +9,6 @@ import { DrawerOverlay, DrawerProps, Flex, - Heading, IconButton, Link, Select, @@ -36,21 +35,9 @@ import { SaveRelaySetForm } from "./save-relay-set-form"; function RelayControl({ url }: { url: string }) { const relay = useMemo(() => relayPoolService.requestRelay(url, false), [url]); - const status = useSubject(relay.status); const writeRelays = useSubject(clientRelaysService.writeRelays); - let color = "gray"; - switch (status) { - case WebSocket.OPEN: - color = "green"; - break; - case WebSocket.CONNECTING: - color = "yellow"; - break; - case WebSocket.CLOSED: - color = "red"; - break; - } + const color = relay.connected ? "green" : "red"; const onChange = () => { if (writeRelays.has(url)) clientRelaysService.removeRelay(url, RelayMode.WRITE); @@ -117,7 +104,7 @@ export default function RelayManagementDrawer({ isOpen, onClose, ...props }: Omi const sorted = useMemo(() => RelaySet.from(readRelays, writeRelays).urls.sort(), [readRelays, writeRelays]); const others = Array.from(relayPoolService.relays.values()) - .filter((r) => !r.closed && !sorted.includes(r.url)) + .filter((r) => !r.connected && !sorted.includes(r.url)) .map((r) => r.url) .sort(); diff --git a/src/components/relay-status.tsx b/src/components/relay-status.tsx index 17f61c40f..1c9b15eef 100644 --- a/src/components/relay-status.tsx +++ b/src/components/relay-status.tsx @@ -1,22 +1,24 @@ import { Badge, useForceUpdate } from "@chakra-ui/react"; import { useInterval } from "react-use"; -import Relay from "../classes/relay"; import relayPoolService from "../services/relay-pool"; +import { Relay } from "nostr-tools"; const getStatusText = (relay: Relay) => { - if (relay.connecting) return "Connecting..."; + // if (relay.connecting) return "Connecting..."; if (relay.connected) return "Connected"; - if (relay.closing) return "Disconnecting..."; - if (relay.closed) return "Disconnected"; - return "Unused"; + // if (relay.closing) return "Disconnecting..."; + // if (relay.closed) return "Disconnected"; + return "Disconnected"; + // return "Unused"; }; const getStatusColor = (relay: Relay) => { - if (relay.connecting) return "yellow"; + // if (relay.connecting) return "yellow"; if (relay.connected) return "green"; - if (relay.closing) return "yellow"; - if (relay.closed) return "red"; - return "gray"; + // if (relay.closing) return "yellow"; + // if (relay.closed) return "red"; + // return "gray"; + return "red"; }; export const RelayStatus = ({ url }: { url: string }) => { diff --git a/src/helpers/nostr/zaps.ts b/src/helpers/nostr/zaps.ts index e75e5356a..64330c801 100644 --- a/src/helpers/nostr/zaps.ts +++ b/src/helpers/nostr/zaps.ts @@ -54,18 +54,38 @@ export type ParsedZap = { const parsedZapSymbol = Symbol("parsedZap"); type ParsedZapEvent = NostrEvent & { [parsedZapSymbol]: ParsedZap | Error }; -export function getParsedZap(event: NostrEvent) { + +export function getParsedZap(event: NostrEvent, quite: false, returnError?: boolean): ParsedZap; +export function getParsedZap(event: NostrEvent, quite: true, returnError: true): ParsedZap | Error; +export function getParsedZap(event: NostrEvent, quite: true, returnError: false): ParsedZap | undefined; +export function getParsedZap(event: NostrEvent, quite?: boolean, returnError?: boolean): ParsedZap | undefined; +export function getParsedZap(event: NostrEvent, quite: boolean = true, returnError?: boolean) { const e = event as ParsedZapEvent; if (Object.hasOwn(e, parsedZapSymbol)) return e[parsedZapSymbol]; try { return (e[parsedZapSymbol] = parseZapEvent(e)); } catch (error) { - if (error instanceof Error) return (e[parsedZapSymbol] = error); - else throw error; + if (error instanceof Error) { + e[parsedZapSymbol] = error; + if (quite) return returnError ? error : undefined; + else throw error; + } else throw error; } } +export function parseZapEvents(events: NostrEvent[]) { + const parsed: ParsedZap[] = []; + + for (const event of events) { + const p = getParsedZap(event); + if (p) parsed.push(p); + } + + return parsed; +} + +/** @deprecated use getParsedZap instead */ export function parseZapEvent(event: NostrEvent): ParsedZap { const zapRequestStr = event.tags.find(([t, v]) => t === "description")?.[1]; if (!zapRequestStr) throw new Error("no description tag"); diff --git a/src/helpers/relay.ts b/src/helpers/relay.ts index 31a334d2e..4003b7e6f 100644 --- a/src/helpers/relay.ts +++ b/src/helpers/relay.ts @@ -1,8 +1,9 @@ import { SimpleRelay, SubscriptionOptions } from "nostr-idb"; -import { Filter } from "nostr-tools"; +import { AbstractRelay, Filter, SubCloser, SubscribeManyParams, Subscription } from "nostr-tools"; import { NostrQuery, NostrRequestFilter } from "../types/nostr-relay"; import { NostrEvent } from "../types/nostr-event"; +import relayPoolService from "../services/relay-pool"; // NOTE: only use this for equality checks and querying export function getRelayVariations(relay: string) { @@ -110,3 +111,81 @@ export function relayRequest(relay: SimpleRelay, filters: Filter[], opts: Subscr }); }); } + +// copied from nostr-tools, SimplePool#subscribeMany +export function subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser { + const _knownIds = new Set(); + const subs: Subscription[] = []; + + // batch all EOSEs into a single + const eosesReceived: boolean[] = []; + let handleEose = (i: number) => { + eosesReceived[i] = true; + if (eosesReceived.filter((a) => a).length === relays.length) { + params.oneose?.(); + handleEose = () => {}; + } + }; + // batch all closes into a single + const closesReceived: string[] = []; + let handleClose = (i: number, reason: string) => { + handleEose(i); + closesReceived[i] = reason; + if (closesReceived.filter((a) => a).length === relays.length) { + params.onclose?.(closesReceived); + handleClose = () => {}; + } + }; + + const localAlreadyHaveEventHandler = (id: string) => { + if (params.alreadyHaveEvent?.(id)) { + return true; + } + const have = _knownIds.has(id); + _knownIds.add(id); + return have; + }; + + // open a subscription in all given relays + const allOpened = Promise.all( + relays.map(validateRelayURL).map(async (url, i, arr) => { + if (arr.indexOf(url) !== i) { + // duplicate + handleClose(i, "duplicate url"); + return; + } + + let relay: AbstractRelay; + try { + relay = relayPoolService.requestRelay(url); + await relay.connect(); + // changed from nostr-tools + // relay = await this.ensureRelay(url, { + // connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined, + // }); + } catch (err) { + handleClose(i, (err as any)?.message || String(err)); + return; + } + + let subscription = relay.subscribe(filters, { + ...params, + oneose: () => handleEose(i), + onclose: (reason) => handleClose(i, reason), + alreadyHaveEvent: localAlreadyHaveEventHandler, + eoseTimeout: params.maxWait, + }); + + subs.push(subscription); + }), + ); + + return { + async close() { + await allOpened; + subs.forEach((sub) => { + sub.close(); + }); + }, + }; +} diff --git a/src/hooks/use-event-zaps.ts b/src/hooks/use-event-zaps.ts index 0e5db265e..decfa4cba 100644 --- a/src/hooks/use-event-zaps.ts +++ b/src/hooks/use-event-zaps.ts @@ -3,7 +3,7 @@ import { useMemo } from "react"; import eventZapsService from "../services/event-zaps"; import { useReadRelays } from "./use-client-relays"; import useSubject from "./use-subject"; -import { parseZapEvent } from "../helpers/nostr/zaps"; +import { getParsedZap } from "../helpers/nostr/zaps"; export default function useEventZaps(eventUID: string, additionalRelays?: Iterable, alwaysRequest = true) { const readRelays = useReadRelays(additionalRelays); @@ -18,9 +18,8 @@ export default function useEventZaps(eventUID: string, additionalRelays?: Iterab const zaps = useMemo(() => { const parsed = []; for (const zap of events) { - try { - parsed.push(parseZapEvent(zap)); - } catch (e) {} + const p = getParsedZap(zap); + if (p) parsed.push(p); } return parsed; }, [events]); diff --git a/src/hooks/use-stream-goal.ts b/src/hooks/use-stream-goal.ts index 8675c1596..0d2b079ae 100644 --- a/src/hooks/use-stream-goal.ts +++ b/src/hooks/use-stream-goal.ts @@ -4,8 +4,9 @@ import { GOAL_KIND } from "../helpers/nostr/goal"; import { ParsedStream, getATag } from "../helpers/nostr/stream"; import { NostrEvent } from "../types/nostr-event"; import { useReadRelays } from "./use-client-relays"; -import NostrRequest from "../classes/nostr-request"; import useSingleEvent from "./use-single-event"; +import { subscribeMany } from "../helpers/relay"; +import { Filter } from "nostr-tools"; export default function useStreamGoal(stream: ParsedStream) { const [goal, setGoal] = useState(); @@ -15,11 +16,11 @@ export default function useStreamGoal(stream: ParsedStream) { useEffect(() => { if (!stream.goal) { - const request = new NostrRequest(readRelays); - request.onEvent.subscribe((event) => { - setGoal(event); + const filter: Filter = { "#a": [getATag(stream)], kinds: [GOAL_KIND] }; + const sub = subscribeMany(Array.from(readRelays), [filter], { + onevent: (event) => setGoal((c) => (!c || event.created_at > c.created_at ? event : c)), + oneose: () => sub.close(), }); - request.start({ "#a": [getATag(stream)], kinds: [GOAL_KIND] }); } }, [stream.identifier, stream.goal, readRelays.urls.join("|")]); diff --git a/src/providers/global/publish-provider.tsx b/src/providers/global/publish-provider.tsx index 4add3b3d8..4d4ffc9c0 100644 --- a/src/providers/global/publish-provider.tsx +++ b/src/providers/global/publish-provider.tsx @@ -66,8 +66,8 @@ export default function PublishProvider({ children }: PropsWithChildren) { const pub = new NostrPublishAction(label, relays, signed); setLog((arr) => arr.concat(pub)); - pub.onResult.subscribe(({ relay, result }) => { - if (result[2]) handleEventFromRelay(relay, signed); + pub.onResult.subscribe(({ relay, success }) => { + if (success) handleEventFromRelay(relay, signed); }); // send it to the local relay diff --git a/src/services/dns-identity.ts b/src/services/dns-identity.ts index 098e94a23..1f9bd0f2b 100644 --- a/src/services/dns-identity.ts +++ b/src/services/dns-identity.ts @@ -40,7 +40,7 @@ class DnsIdentityService { async fetchIdentity(address: string) { const { name, domain } = parseAddress(address); - if (!name || !domain) throw new Error("invalid address"); + if (!name || !domain) throw new Error("invalid address " + address); const json = await fetchWithCorsFallback(`https://${domain}/.well-known/nostr.json?name=${name}`) .then((res) => res.json() as Promise) diff --git a/src/services/event-exists.ts b/src/services/event-exists.ts index 9032520c6..d747203f1 100644 --- a/src/services/event-exists.ts +++ b/src/services/event-exists.ts @@ -3,13 +3,13 @@ import stringify from "json-stringify-deterministic"; import Subject from "../classes/subject"; import { NostrRequestFilter } from "../types/nostr-relay"; import SuperMap from "../classes/super-map"; -import NostrRequest from "../classes/nostr-request"; import relayScoreboardService from "./relay-scoreboard"; import { logger } from "../helpers/debug"; import { matchFilter, matchFilters } from "nostr-tools"; import { NostrEvent } from "../types/nostr-event"; import { relayRequest } from "../helpers/relay"; import { localRelay } from "./local-relay"; +import relayPoolService from "./relay-pool"; function hashFilter(filter: NostrRequestFilter) { return stringify(filter); @@ -62,20 +62,23 @@ class EventExistsService { relays.delete(nextRelay); (async () => { - const sub = this.answers.get(key); - const request = new NostrRequest([nextRelay], 500); - const limitFilter = Array.isArray(filter) ? filter.map((f) => ({ ...f, limit: 1 })) : { ...filter, limit: 1 }; - request.start(limitFilter); - request.onEvent.subscribe(() => { - this.log("Found event for", filter); - sub.next(true); - this.pending.delete(key); + const subject = this.answers.get(key); + const limitFilter = Array.isArray(filter) ? filter.map((f) => ({ ...f, limit: 1 })) : [{ ...filter, limit: 1 }]; + const subscription = relayPoolService.requestRelay(nextRelay).subscribe(limitFilter, { + eoseTimeout: 500, + onevent: () => { + this.log("Found event for", filter); + subject.next(true); + this.pending.delete(key); + }, + oneose: () => { + if (subject.value === undefined && this.asked.get(key).size > this.pending.get(key).size) { + this.log("Could not find event for", filter); + subject.next(false); + } + subscription.close(); + }, }); - await request.onComplete; - if (sub.value === undefined && this.asked.get(key).size > this.pending.get(key).size) { - this.log("Could not find event for", filter); - sub.next(false); - } })(); } } diff --git a/src/services/event-reactions.ts b/src/services/event-reactions.ts index 60ebae9f1..71053cd08 100644 --- a/src/services/event-reactions.ts +++ b/src/services/event-reactions.ts @@ -1,12 +1,12 @@ import { Filter, kinds, nip25 } from "nostr-tools"; import _throttle from "lodash.throttle"; -import NostrRequest from "../classes/nostr-request"; import Subject from "../classes/subject"; import SuperMap from "../classes/super-map"; import { NostrEvent } from "../types/nostr-event"; import { localRelay } from "./local-relay"; import { relayRequest } from "../helpers/relay"; +import relayPoolService from "./relay-pool"; type eventId = string; type relay = string; @@ -73,9 +73,9 @@ class EventReactionsService { if (coordinates.length > 0) filters.push({ "#a": coordinates, kinds: [kinds.Reaction] }); if (filters.length > 0) { - const request = new NostrRequest([relay]); - request.onEvent.subscribe((e) => this.handleEvent(e)); - request.start(filters); + const subscription = relayPoolService + .requestRelay(relay) + .subscribe(filters, { onevent: (event) => this.handleEvent(event), oneose: () => subscription.close() }); } } this.pending.clear(); diff --git a/src/services/event-relays.ts b/src/services/event-relays.ts index ec2bd5555..cc8918204 100644 --- a/src/services/event-relays.ts +++ b/src/services/event-relays.ts @@ -1,8 +1,7 @@ -import Relay from "../classes/relay"; +import { Relay } from "nostr-tools"; import { PersistentSubject } from "../classes/subject"; import { getEventUID } from "../helpers/nostr/event"; import { NostrEvent } from "../types/nostr-event"; -import relayPoolService from "./relay-pool"; const eventRelays = new Map>(); @@ -30,11 +29,12 @@ export function handleEventFromRelay(relay: Relay, event: NostrEvent) { if (event.id !== uid) addRelay(event.id, relay.url); } -relayPoolService.onRelayCreated.subscribe((relay) => { - relay.onEvent.subscribe((message) => { - handleEventFromRelay(relay, message[2]); - }); -}); +// TODO: track events from relays +// relayPoolService.onRelayCreated.subscribe((relay) => { +// relay.onEvent.subscribe((message) => { +// handleEventFromRelay(relay, message[2]); +// }); +// }); const eventRelaysService = { getEventRelays, diff --git a/src/services/event-zaps.ts b/src/services/event-zaps.ts index 9a9679921..36544a703 100644 --- a/src/services/event-zaps.ts +++ b/src/services/event-zaps.ts @@ -1,12 +1,12 @@ import { Filter, kinds } from "nostr-tools"; import _throttle from "lodash.throttle"; -import NostrRequest from "../classes/nostr-request"; import Subject from "../classes/subject"; import SuperMap from "../classes/super-map"; import { NostrEvent, isATag, isETag } from "../types/nostr-event"; import { relayRequest } from "../helpers/relay"; import { localRelay } from "./local-relay"; +import relayPoolService from "./relay-pool"; type eventUID = string; type relay = string; @@ -73,9 +73,9 @@ class EventZapsService { if (coordinates.length > 0) filter.push({ "#a": coordinates, kinds: [kinds.Zap] }); if (filter.length > 0) { - const request = new NostrRequest([relay]); - request.onEvent.subscribe((e) => this.handleEvent(e)); - request.start(filter); + const sub = relayPoolService + .requestRelay(relay) + .subscribe(filter, { onevent: (event) => this.handleEvent(event), oneose: () => sub.close() }); } } this.pending.clear(); diff --git a/src/services/nostr-connect.ts b/src/services/nostr-connect.ts index 1e7b55e18..c31111ac5 100644 --- a/src/services/nostr-connect.ts +++ b/src/services/nostr-connect.ts @@ -57,7 +57,8 @@ export type NostrConnectErrorResponse = { }; // FIXME list all requested perms -const Perms = "nip04_encrypt,nip04_decrypt,sign_event:0,sign_event:1,sign_event:3,sign_event:4,sign_event:6,sign_event:7" +const Perms = + "nip04_encrypt,nip04_decrypt,sign_event:0,sign_event:1,sign_event:3,sign_event:4,sign_event:6,sign_event:7"; export class NostrConnectClient { sub: NostrMultiSubscription; @@ -93,7 +94,7 @@ export class NostrConnectClient { async open() { this.sub.open(); - await this.sub.waitForConnection(); + await this.sub.waitForAllConnection(); this.log("Connected to relays", this.relays); } close() { @@ -126,7 +127,7 @@ export class NostrConnectClient { this.log("Got Error", response.id, response.result, response.error); if (response.result === "auth_url") { if (!this.auths.has(response.id)) { - this.auths.add(response.id) + this.auths.add(response.id); try { await this.handleAuthURL(response.error); } catch (e) { @@ -163,7 +164,7 @@ export class NostrConnectClient { const encrypted = await nip04.encrypt(this.secretKey, this.pubkey, JSON.stringify(request)); const event = this.createEvent(encrypted, this.pubkey, kind); this.log(`Sending request ${id} (${method}) ${JSON.stringify(params)}`, event); - this.sub.sendAll(event); + this.sub.publish(event); const p = createDefer(); this.requests.set(id, p); @@ -180,7 +181,7 @@ export class NostrConnectClient { const encrypted = await nip04.encrypt(this.secretKey, this.provider, JSON.stringify(request)); const event = this.createEvent(encrypted, this.provider, kind); this.log(`Sending admin request ${id} (${method}) ${JSON.stringify(params)}`, event); - this.sub.sendAll(event); + this.sub.publish(event); const p = createDefer(); this.requests.set(id, p); @@ -190,10 +191,7 @@ export class NostrConnectClient { async connect(token?: string) { await this.open(); try { - const result = await this.makeRequest( - NostrConnectMethod.Connect, - [this.pubkey, token || '', Perms], - ); + const result = await this.makeRequest(NostrConnectMethod.Connect, [this.pubkey, token || "", Perms]); this.isConnected = true; return result; } catch (e) { @@ -207,10 +205,12 @@ export class NostrConnectClient { await this.open(); try { - const newPubkey = await this.makeAdminRequest( - NostrConnectMethod.CreateAccount, - [name, domain, email || '', Perms], - ); + const newPubkey = await this.makeAdminRequest(NostrConnectMethod.CreateAccount, [ + name, + domain, + email || "", + Perms, + ]); this.pubkey = newPubkey; this.isConnected = true; return newPubkey; diff --git a/src/services/relay-stats.ts b/src/services/relay-stats.ts index ee39b1cd1..7d6378b22 100644 --- a/src/services/relay-stats.ts +++ b/src/services/relay-stats.ts @@ -1,12 +1,13 @@ import _throttle from "lodash/throttle"; -import NostrRequest from "../classes/nostr-request"; import Subject from "../classes/subject"; import SuperMap from "../classes/super-map"; import { NostrEvent } from "../types/nostr-event"; import relayInfoService from "./relay-info"; import { localRelay } from "./local-relay"; import { MONITOR_STATS_KIND, SELF_REPORTED_KIND, getRelayURL } from "../helpers/nostr/relay-stats"; +import relayPoolService from "./relay-pool"; +import { Filter } from "nostr-tools"; const MONITOR_PUBKEY = "151c17c9d234320cf0f189af7b761f63419fd6c38c6041587a008b7682e4640f"; const MONITOR_RELAY = "wss://history.nostr.watch"; @@ -50,9 +51,10 @@ class RelayStatsService { relayInfoService.getInfo(relay).then((info) => { if (!info.pubkey) return sub.next(null); - const request = new NostrRequest([relay, MONITOR_RELAY]); - request.onEvent.subscribe((e) => this.handleEvent(e)); - request.start({ kinds: [SELF_REPORTED_KIND], authors: [info.pubkey] }); + const filter: Filter = { kinds: [SELF_REPORTED_KIND], authors: [info.pubkey] }; + const subscription = relayPoolService + .requestRelay(MONITOR_RELAY) + .subscribe([filter], { onevent: (event) => this.handleEvent(event), oneose: () => subscription.close() }); }); } @@ -74,9 +76,10 @@ class RelayStatsService { private batchRequestMonitorStats() { const relays = Array.from(this.pendingMonitorStats); - const request = new NostrRequest([MONITOR_RELAY]); - request.onEvent.subscribe((e) => this.handleEvent(e)); - request.start({ since: 1704196800, kinds: [MONITOR_STATS_KIND], "#d": relays, authors: [MONITOR_PUBKEY] }); + const filter: Filter = { since: 1704196800, kinds: [MONITOR_STATS_KIND], "#d": relays, authors: [MONITOR_PUBKEY] }; + const sub = relayPoolService + .requestRelay(MONITOR_RELAY) + .subscribe([filter], { onevent: (event) => this.handleEvent(event), oneose: () => sub.close() }); this.pendingMonitorStats.clear(); } diff --git a/src/services/replaceable-events.ts b/src/services/replaceable-events.ts index 7648e42b1..4169d2009 100644 --- a/src/services/replaceable-events.ts +++ b/src/services/replaceable-events.ts @@ -11,6 +11,7 @@ import { relayRequest } from "../helpers/relay"; import EventStore from "../classes/event-store"; import Subject from "../classes/subject"; import BatchKindLoader, { createCoordinate } from "../classes/batch-kind-loader"; +import relayPoolService from "./relay-pool"; export type RequestOptions = { /** Always request the event from the relays */ @@ -31,7 +32,7 @@ const WRITE_CACHE_BATCH_TIME = 250; class ReplaceableEventsService { private subjects = new SuperMap>(() => new Subject()); private loaders = new SuperMap((relay) => { - const loader = new BatchKindLoader(relay, this.log.extend(relay)); + const loader = new BatchKindLoader(relayPoolService.requestRelay(relay), this.log.extend(relay)); loader.events.onEvent.subscribe((e) => this.handleEvent(e)); return loader; }); diff --git a/src/services/single-event.ts b/src/services/single-event.ts index 54c6849fe..5eb99c9f8 100644 --- a/src/services/single-event.ts +++ b/src/services/single-event.ts @@ -1,12 +1,12 @@ import _throttle from "lodash.throttle"; -import NostrRequest from "../classes/nostr-request"; import SuperMap from "../classes/super-map"; import { NostrEvent } from "../types/nostr-event"; import { localRelay } from "./local-relay"; import { relayRequest, safeRelayUrls } from "../helpers/relay"; import { logger } from "../helpers/debug"; import Subject from "../classes/subject"; +import relayPoolService from "./relay-pool"; const RELAY_REQUEST_BATCH_TIME = 500; @@ -64,9 +64,9 @@ class SingleEventService { } for (const [relay, ids] of Object.entries(idsFromRelays)) { - const request = new NostrRequest([relay]); - request.onEvent.subscribe((event) => this.handleEvent(event)); - request.start({ ids }); + const sub = relayPoolService + .requestRelay(relay) + .subscribe([{ ids }], { onevent: (event) => this.handleEvent(event), oneose: () => sub.close() }); } this.pending.clear(); } diff --git a/src/views/notifications/components/notification-item.tsx b/src/views/notifications/components/notification-item.tsx index 131e593f8..a9f8c3037 100644 --- a/src/views/notifications/components/notification-item.tsx +++ b/src/views/notifications/components/notification-item.tsx @@ -5,7 +5,7 @@ import { kinds, nip18, nip25 } from "nostr-tools"; import useCurrentAccount from "../../../hooks/use-current-account"; import { NostrEvent, isATag, isETag } from "../../../types/nostr-event"; import { useRegisterIntersectionEntity } from "../../../providers/local/intersection-observer"; -import { parseZapEvent } from "../../../helpers/nostr/zaps"; +import { getParsedZap } from "../../../helpers/nostr/zaps"; import { readablizeSats } from "../../../helpers/bolt11"; import { getEventUID, parseCoordinate } from "../../../helpers/nostr/event"; import { EmbedEvent, EmbedEventPointer } from "../../../components/embed-event"; @@ -93,11 +93,7 @@ const ReactionNotification = forwardRef(( }); const ZapNotification = forwardRef(({ event }, ref) => { - const zap = useMemo(() => { - try { - return parseZapEvent(event); - } catch (e) {} - }, [event]); + const zap = useMemo(() => getParsedZap(event), [event]); if (!zap || !zap.payment.amount) return null; diff --git a/src/views/relays/app/relay-control.tsx b/src/views/relays/app/relay-control.tsx index 59c5446b7..8f5664aad 100644 --- a/src/views/relays/app/relay-control.tsx +++ b/src/views/relays/app/relay-control.tsx @@ -12,21 +12,9 @@ import UploadCloud01 from "../../../components/icons/upload-cloud-01"; export default function RelayControl({ url }: { url: string }) { const relay = useMemo(() => relayPoolService.requestRelay(url, false), [url]); - const status = useSubject(relay.status); const writeRelays = useSubject(clientRelaysService.writeRelays); - let color = "gray"; - switch (status) { - case WebSocket.OPEN: - color = "green"; - break; - case WebSocket.CONNECTING: - color = "yellow"; - break; - case WebSocket.CLOSED: - color = "red"; - break; - } + const color = relay.connected ? "green" : "red"; const onChange = () => { if (writeRelays.has(url)) clientRelaysService.removeRelay(url, RelayMode.WRITE); diff --git a/src/views/relays/mailboxes/index.tsx b/src/views/relays/mailboxes/index.tsx index 7a3d223cb..e9410c993 100644 --- a/src/views/relays/mailboxes/index.tsx +++ b/src/views/relays/mailboxes/index.tsx @@ -1,3 +1,4 @@ +import { useCallback } from "react"; import { Flex, Heading, IconButton, Link, Text } from "@chakra-ui/react"; import { CloseIcon } from "@chakra-ui/icons"; import { Link as RouterLink } from "react-router-dom"; @@ -8,7 +9,6 @@ import useCurrentAccount from "../../../hooks/use-current-account"; import { InboxIcon, OutboxIcon } from "../../../components/icons"; import MediaServerFavicon from "../../../components/media-server/media-server-favicon"; import { RelayMode } from "../../../classes/relay"; -import { useCallback } from "react"; import { NostrEvent } from "../../../types/nostr-event"; import useAsyncErrorHandler from "../../../hooks/use-async-error-handler"; import { usePublishEvent } from "../../../providers/global/publish-provider"; diff --git a/src/views/relays/relay/relay-details.tsx b/src/views/relays/relay/relay-details.tsx index da48b297f..d0c37e4fb 100644 --- a/src/views/relays/relay/relay-details.tsx +++ b/src/views/relays/relay/relay-details.tsx @@ -37,9 +37,9 @@ import { NostrEvent } from "../../../types/nostr-event"; import { groupByTime } from "../../../helpers/notification"; import { useCallback, useEffect, useMemo, useState } from "react"; import EventStore from "../../../classes/event-store"; -import NostrRequest from "../../../classes/nostr-request"; import { sortByDate } from "../../../helpers/nostr/event"; import { NostrQuery } from "../../../types/nostr-relay"; +import relayPoolService from "../../../services/relay-pool"; ChartJS.register( ArcElement, @@ -154,18 +154,21 @@ export default function RelayDetailsTab({ relay }: { relay: string }) { const [loading, setLoading] = useState(false); const loadMore = useCallback(() => { setLoading(true); - const request = new NostrRequest([relay]); - const throttle = _throttle(() => update({}), 100); - request.onEvent.subscribe((e) => { - store.addEvent(e); - throttle(); - }); - request.onComplete.then(() => setLoading(false)); - const query: NostrQuery = { limit: 500 }; const last = store.getLastEvent(); if (last) query.until = last.created_at; - request.start(query); + + const throttleUpdate = _throttle(() => update({}), 100); + const sub = relayPoolService.requestRelay(relay).subscribe([query], { + onevent: (event) => { + store.addEvent(event); + throttleUpdate(); + }, + oneose: () => sub.close(), + onclose: () => { + setLoading(false); + }, + }); }, [relay, update, store]); useEffect(() => loadMore(), [relay, loadMore]); diff --git a/src/views/streams/components/top-zappers.tsx b/src/views/streams/components/top-zappers.tsx index 17fe86507..43667add0 100644 --- a/src/views/streams/components/top-zappers.tsx +++ b/src/views/streams/components/top-zappers.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { Flex, FlexProps, Text } from "@chakra-ui/react"; -import { parseZapEvent } from "../../../helpers/nostr/zaps"; +import { parseZapEvents } from "../../../helpers/nostr/zaps"; import UserLink from "../../../components/user/user-link"; import { LightningIcon } from "../../../components/icons"; import { readablizeSats } from "../../../helpers/bolt11"; @@ -13,15 +13,7 @@ import UserAvatarLink from "../../../components/user/user-avatar-link"; export default function TopZappers({ stream, ...props }: FlexProps & { stream: ParsedStream }) { const timeline = useStreamChatTimeline(stream); const events = useSubject(timeline.timeline); - const zaps = useMemo(() => { - const parsed = []; - for (const event of events) { - try { - parsed.push(parseZapEvent(event)); - } catch (e) {} - } - return parsed; - }, [events]); + const zaps = useMemo(() => parseZapEvents(events), [events]); const totals: Record = {}; for (const zap of zaps) { diff --git a/src/views/streams/stream/stream-chat/zap-message.tsx b/src/views/streams/stream/stream-chat/zap-message.tsx index 46c71bdd2..8785b5871 100644 --- a/src/views/streams/stream/stream-chat/zap-message.tsx +++ b/src/views/streams/stream/stream-chat/zap-message.tsx @@ -7,7 +7,7 @@ import UserLink from "../../../../components/user/user-link"; import { NostrEvent } from "../../../../types/nostr-event"; import { useRegisterIntersectionEntity } from "../../../../providers/local/intersection-observer"; import { LightningIcon } from "../../../../components/icons"; -import { parseZapEvent } from "../../../../helpers/nostr/zaps"; +import { getParsedZap } from "../../../../helpers/nostr/zaps"; import { readablizeSats } from "../../../../helpers/bolt11"; import { TrustProvider } from "../../../../providers/local/trust"; import ChatMessageContent from "./chat-message-content"; @@ -17,11 +17,7 @@ function ZapMessage({ zap, stream }: { zap: NostrEvent; stream: ParsedStream }) const ref = useRef(null); useRegisterIntersectionEntity(ref, zap.id); - const parsed = useMemo(() => { - try { - return parseZapEvent(zap); - } catch (e) {} - }, [zap]); + const parsed = useMemo(() => getParsedZap(zap), [zap]); const clientMuteFilter = useClientSideMuteFilter(); if (!parsed || !parsed.payment.amount) return null; diff --git a/src/views/tools/network-dm-graph.tsx b/src/views/tools/network-dm-graph.tsx index 56dcf9e1b..09a269f3a 100644 --- a/src/views/tools/network-dm-graph.tsx +++ b/src/views/tools/network-dm-graph.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { Box, Button, Flex, Input, Text } from "@chakra-ui/react"; import AutoSizer from "react-virtualized-auto-sizer"; import ForceGraph, { LinkObject, NodeObject } from "react-force-graph-3d"; -import { kinds } from "nostr-tools"; +import { Filter, kinds } from "nostr-tools"; import dayjs from "dayjs"; import { useNavigate } from "react-router-dom"; import { useDebounce, useObservable } from "react-use"; @@ -24,10 +24,10 @@ import { getPubkeysFromList } from "../../helpers/nostr/lists"; import useUserContactList from "../../hooks/use-user-contact-list"; import useUserMetadata from "../../hooks/use-user-metadata"; import EventStore from "../../classes/event-store"; -import NostrRequest from "../../classes/nostr-request"; import { isPTag } from "../../types/nostr-event"; import { ChevronLeftIcon } from "../../components/icons"; import { useReadRelays } from "../../hooks/use-client-relays"; +import { subscribeMany } from "../../helpers/relay"; type NodeType = { id: string; image?: string; name?: string }; @@ -51,13 +51,15 @@ function NetworkDMGraphPage() { if (!contacts) return; store.clear(); - const request = new NostrRequest(relays); - request.onEvent.subscribe((e) => store.addEvent(e)); - request.start({ + const filter: Filter = { authors: contactsPubkeys, kinds: [kinds.EncryptedDirectMessage], since, until, + }; + const sub = subscribeMany(Array.from(relays), [filter], { + onevent: (event) => store.addEvent(event), + oneose: () => sub.close(), }); }, 2 * 1000, diff --git a/src/views/user/zaps.tsx b/src/views/user/zaps.tsx index e09c96901..af2c2de84 100644 --- a/src/views/user/zaps.tsx +++ b/src/views/user/zaps.tsx @@ -3,15 +3,14 @@ import dayjs from "dayjs"; import { ReactNode, useCallback, useMemo, useRef, useState } from "react"; import { useOutletContext } from "react-router-dom"; -import { ErrorBoundary, ErrorFallback } from "../../components/error-boundary"; +import { ErrorBoundary } from "../../components/error-boundary"; import { LightningIcon } from "../../components/icons"; -import { NoteLink } from "../../components/note/note-link"; import UserAvatarLink from "../../components/user/user-avatar-link"; import UserLink from "../../components/user/user-link"; import { readablizeSats } from "../../helpers/bolt11"; -import { isProfileZap, isNoteZap, parseZapEvent, totalZaps } from "../../helpers/nostr/zaps"; +import { isProfileZap, isNoteZap, totalZaps, parseZapEvents, getParsedZap } from "../../helpers/nostr/zaps"; import useTimelineLoader from "../../hooks/use-timeline-loader"; -import { NostrEvent, isATag, isETag, isPTag } from "../../types/nostr-event"; +import { NostrEvent, isATag, isETag } from "../../types/nostr-event"; import { useAdditionalRelayContext } from "../../providers/local/additional-relay-context"; import { useReadRelays } from "../../hooks/use-client-relays"; import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status"; @@ -23,7 +22,7 @@ import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline- import { EmbedableContent, embedUrls } from "../../helpers/embeds"; import { embedNostrLinks, renderGenericUrl } from "../../components/embed-types"; import Timestamp from "../../components/timestamp"; -import { EmbedEventNostrLink, EmbedEventPointer } from "../../components/embed-event"; +import { EmbedEventPointer } from "../../components/embed-event"; import { parseCoordinate } from "../../helpers/nostr/event"; import VerticalPageLayout from "../../components/vertical-page-layout"; @@ -31,7 +30,7 @@ const Zap = ({ zapEvent }: { zapEvent: NostrEvent }) => { const ref = useRef(null); useRegisterIntersectionEntity(ref, zapEvent.id); - const { request, payment } = parseZapEvent(zapEvent); + const { request, payment } = getParsedZap(zapEvent, false); const eventId = request.tags.find(isETag)?.[1]; const coordinate = request.tags.find(isATag)?.[1]; @@ -101,15 +100,7 @@ const UserZapsTab = () => { const timeline = useTimelineLoader(`${pubkey}-zaps`, relays, { "#p": [pubkey], kinds: [9735] }, { eventFilter }); const events = useSubject(timeline.timeline); - const zaps = useMemo(() => { - const parsed = []; - for (const zap of events) { - try { - parsed.push(parseZapEvent(zap)); - } catch (e) {} - } - return parsed; - }, [events]); + const zaps = useMemo(() => parseZapEvents(events), [events]); const callback = useTimelineCurserIntersectionCallback(timeline); diff --git a/yarn.lock b/yarn.lock index d4a51ff08..e9ea93bc3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5435,6 +5435,20 @@ nostr-tools@^2.3.2: optionalDependencies: nostr-wasm v0.1.0 +nostr-tools@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.4.0.tgz#bc2140a95ce0be8d4900bd852f652d811562753e" + integrity sha512-xQC7XdGeh0gLyprcKhvx5lwr7OQ+ZOiQ9C6GpzlVAj+EBv+AiN8kySb57t3uJoG1HK15oT9jf++MmQLwhp1xNQ== + dependencies: + "@noble/ciphers" "^0.5.1" + "@noble/curves" "1.2.0" + "@noble/hashes" "1.3.1" + "@scure/base" "1.1.1" + "@scure/bip32" "1.3.1" + "@scure/bip39" "1.2.1" + optionalDependencies: + nostr-wasm v0.1.0 + nostr-wasm@v0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/nostr-wasm/-/nostr-wasm-0.1.0.tgz#17af486745feb2b7dd29503fdd81613a24058d94"