diff --git a/.changeset/gentle-deers-fold.md b/.changeset/gentle-deers-fold.md new file mode 100644 index 000000000..7fc764a73 --- /dev/null +++ b/.changeset/gentle-deers-fold.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Show timelines, subscriptions, and services in task manager diff --git a/package.json b/package.json index 2e34332fb..02ba05acd 100644 --- a/package.json +++ b/package.json @@ -67,8 +67,8 @@ "match-sorter": "^6.3.1", "nanoid": "^5.0.4", "ngeohash": "^0.6.3", - "nostr-idb": "^2.1.1", - "nostr-tools": "^2.5.0", + "nostr-idb": "^2.1.4", + "nostr-tools": "^2.5.1", "nostr-wasm": "^0.1.0", "react": "^18.2.0", "react-chartjs-2": "^5.2.0", diff --git a/src/classes/batch-kind-loader.ts b/src/classes/batch-kind-loader.ts index 7fb1a2d4a..93c5bdf94 100644 --- a/src/classes/batch-kind-loader.ts +++ b/src/classes/batch-kind-loader.ts @@ -1,10 +1,14 @@ import dayjs from "dayjs"; -import { Filter, NostrEvent, Relay, Subscription } from "nostr-tools"; +import { Filter, NostrEvent, AbstractRelay } from "nostr-tools"; import _throttle from "lodash.throttle"; import debug, { Debugger } from "debug"; import EventStore from "./event-store"; import { getEventCoordinate } from "../helpers/nostr/event"; +import PersistentSubscription from "./persistent-subscription"; +import Process from "./process"; +import BracketsX from "../components/icons/brackets-x"; +import processManager from "../services/process-manager"; export function createCoordinate(kind: number, pubkey: string, d?: string) { return `${kind}:${pubkey}${d ? ":" + d : ""}`; @@ -14,18 +18,22 @@ 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: Subscription | null = null; + private subscription: PersistentSubscription | null = null; events = new EventStore(); - relay: Relay; + relay: AbstractRelay; + process: Process; private requestNext = new Set(); private requested = new Map(); log: Debugger; - constructor(relay: Relay, log?: Debugger) { + constructor(relay: AbstractRelay, log?: Debugger) { this.relay = relay; - this.log = log || debug("RelayBatchLoader"); + this.log = log || debug("BatchKindLoader"); + this.process = new Process("BatchKindLoader", this, [relay]); + this.process.icon = BracketsX; + processManager.registerProcess(this.process); } private handleEvent(event: NostrEvent) { @@ -55,7 +63,7 @@ export default class BatchKindLoader { } updateThrottle = _throttle(this.update, RELAY_REQUEST_BATCH_TIME); - update() { + async update() { let needsUpdate = false; for (const key of this.requestNext) { if (!this.requested.has(key)) { @@ -102,18 +110,26 @@ export default class BatchKindLoader { .join(", "), ); - if (!this.subscription || this.subscription.closed) { - this.subscription = this.relay.subscribe(query, { + if (!this.subscription) { + this.subscription = new PersistentSubscription(this.relay, { onevent: (event) => this.handleEvent(event), oneose: () => this.handleEOSE(), }); - } else { - this.subscription.filters = query; - this.subscription.fire(); + this.process.addChild(this.subscription.process); } - } else if (this.subscription && !this.subscription.closed) { + + this.subscription.filters = query; + this.subscription.fire(); + this.process.active = true; + } else if (this.subscription) { this.subscription.close(); + this.process.active = false; } } } + + destroy() { + this.process.remove(); + processManager.unregisterProcess(this.process); + } } diff --git a/src/classes/chunked-request.ts b/src/classes/chunked-request.ts index 97a6c8108..4d8632809 100644 --- a/src/classes/chunked-request.ts +++ b/src/classes/chunked-request.ts @@ -1,6 +1,8 @@ import { Debugger } from "debug"; -import { Filter, NostrEvent, matchFilters } from "nostr-tools"; +import { AbstractRelay, Filter, NostrEvent, matchFilters } from "nostr-tools"; +import { SimpleRelay } from "nostr-idb"; import _throttle from "lodash.throttle"; +import { nanoid } from "nanoid"; import Subject from "./subject"; import { logger } from "../helpers/debug"; @@ -9,14 +11,18 @@ 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"; -import { SimpleRelay } from "nostr-idb"; +import Process from "./process"; +import processManager from "../services/process-manager"; +import LayersThree01 from "../components/icons/layers-three-01"; const DEFAULT_CHUNK_SIZE = 100; export type EventFilter = (event: NostrEvent, store: EventStore) => boolean; export default class ChunkedRequest { - relay: SimpleRelay; + id: string; + process: Process; + relay: AbstractRelay; filters: Filter[]; chunkSize = DEFAULT_CHUNK_SIZE; private log: Debugger; @@ -29,8 +35,11 @@ export default class ChunkedRequest { onChunkFinish = new Subject(); - constructor(relay: SimpleRelay, filters: Filter[], log?: Debugger) { - this.relay = relay; + constructor(relay: SimpleRelay | AbstractRelay, filters: Filter[], log?: Debugger) { + this.id = nanoid(8); + this.process = new Process("ChunkedRequest", this, [relay]); + this.process.icon = LayersThree01; + this.relay = relay as AbstractRelay; this.filters = filters; this.log = log || logger.extend(relay.url); @@ -38,35 +47,50 @@ export default class ChunkedRequest { // TODO: find a better place for this this.subs.push(deleteEventService.stream.subscribe((e) => this.handleDeleteEvent(e))); + + processManager.registerProcess(this.process); } - loadNextChunk() { + async loadNextChunk() { + if (this.loading) return; this.loading = true; + + if (!this.relay.connected) { + this.log("relay not connected, aborting"); + relayPoolService.requestConnect(this.relay); + return; + } + let filters: Filter[] = mergeFilter(this.filters, { limit: this.chunkSize }); let oldestEvent = this.getLastEvent(); if (oldestEvent) { filters = mergeFilter(filters, { until: oldestEvent.created_at - 1 }); } - relayPoolService.addClaim(this.relay.url, this); - let gotEvents = 0; - if (filters.length === 0) debugger; - 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.url, this); - }, + + this.process.active = true; + await new Promise((res) => { + 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(); + this.process.active = false; + res(gotEvents); + }, + }); }); } @@ -83,15 +107,16 @@ export default class ChunkedRequest { if (eventId) this.events.deleteEvent(eventId); } - cleanup() { - for (const sub of this.subs) sub.unsubscribe(); - this.subs = []; - } - getFirstEvent(nth = 0, eventFilter?: EventFilter) { return this.events.getFirstEvent(nth, eventFilter); } getLastEvent(nth = 0, eventFilter?: EventFilter) { return this.events.getLastEvent(nth, eventFilter); } + + destroy() { + for (const sub of this.subs) sub.unsubscribe(); + this.subs = []; + processManager.unregisterProcess(this.process); + } } diff --git a/src/classes/memory-relay.ts b/src/classes/memory-relay.ts index bbbdfa450..5b8304cc2 100644 --- a/src/classes/memory-relay.ts +++ b/src/classes/memory-relay.ts @@ -51,9 +51,12 @@ export default class MemoryRelay implements SimpleRelay { subscribe(filters: Filter[], options: SubscriptionOptions) { const sub: Subscription = { - id: nanoid(), + id: nanoid(8), filters, ...options, + fire: () => { + this.executeSubscription(sub); + }, close: () => { this.subscriptions.delete(sub.id); }, diff --git a/src/classes/multi-subscription.ts b/src/classes/multi-subscription.ts new file mode 100644 index 000000000..169a167dd --- /dev/null +++ b/src/classes/multi-subscription.ts @@ -0,0 +1,169 @@ +import { nanoid } from "nanoid"; + +import { NostrEvent } from "../types/nostr-event"; +import relayPoolService from "../services/relay-pool"; +import { isFilterEqual } from "../helpers/nostr/filter"; +import ControlledObservable from "./controlled-observable"; +import { AbstractRelay, Filter } from "nostr-tools"; +import { offlineMode } from "../services/offline-mode"; +import PersistentSubscription from "./persistent-subscription"; +import Process from "./process"; +import Dataflow01 from "../components/icons/dataflow-01"; +import processManager from "../services/process-manager"; +import { localRelay } from "../services/local-relay"; + +export default class MultiSubscription { + static OPEN = "open"; + static CLOSED = "closed"; + + id: string; + name: string; + process: Process; + filters: Filter[] = []; + + relays = new Set(); + subscriptions = new Map(); + cacheSubscription: PersistentSubscription | null = null; + + state = MultiSubscription.CLOSED; + onEvent = new ControlledObservable(); + seenEvents = new Set(); + + constructor(name: string) { + this.id = nanoid(8); + this.name = name; + this.process = new Process("MultiSubscription", this); + this.process.name = this.name; + this.process.icon = Dataflow01; + + processManager.registerProcess(this.process); + } + private handleEvent(event: NostrEvent) { + if (this.seenEvents.has(event.id)) return; + this.onEvent.next(event); + this.seenEvents.add(event.id); + } + + setFilters(filters: Filter[]) { + if (isFilterEqual(this.filters, filters)) return; + this.filters = filters; + this.updateSubscriptions(); + } + + setRelays(relays: Iterable) { + const newRelays = relayPoolService.getRelays(relays); + + // remove relays + for (const relay of this.relays) { + if (!newRelays.includes(relay)) { + this.relays.delete(relay); + const sub = this.subscriptions.get(relay); + if (sub) { + sub.destroy(); + this.subscriptions.delete(relay); + } + } + } + + // add relays + for (const relay of newRelays) { + this.relays.add(relay); + } + + this.process.relays = new Set(this.relays); + this.updateSubscriptions(); + } + + private updateSubscriptions() { + // close all subscriptions if not open + if (this.state !== MultiSubscription.OPEN) { + for (const [relay, subscription] of this.subscriptions) subscription.close(); + this.cacheSubscription?.close(); + return; + } + + // else open and update subscriptions + for (const relay of this.relays) { + let subscription = this.subscriptions.get(relay); + if (!subscription || !isFilterEqual(subscription.filters, this.filters)) { + if (!subscription) { + subscription = new PersistentSubscription(relay, { + onevent: (event) => this.handleEvent(event), + }); + + this.process.addChild(subscription.process); + this.subscriptions.set(relay, subscription); + } + + if (subscription) { + subscription.filters = this.filters; + subscription.fire(); + } + } + } + + // create cache sub if it does not exist + if (!this.cacheSubscription) { + this.cacheSubscription = new PersistentSubscription(localRelay as AbstractRelay, { + onevent: (event) => this.handleEvent(event), + }); + this.process.addChild(this.cacheSubscription.process); + } + + // update cache sub filters if they changed + if (!isFilterEqual(this.cacheSubscription.filters, this.filters)) { + this.cacheSubscription.filters = this.filters; + this.cacheSubscription.fire(); + } + } + + publish(event: NostrEvent) { + return Promise.allSettled( + Array.from(this.relays).map(async (r) => { + if (!r.connected) await relayPoolService.requestConnect(r); + return await r.publish(event); + }), + ); + } + + open() { + if (this.state === MultiSubscription.OPEN) return this; + + this.state = MultiSubscription.OPEN; + this.updateSubscriptions(); + this.process.active = true; + + return this; + } + waitForAllConnection(): Promise { + if (offlineMode.value) return Promise.resolve(); + return Promise.allSettled( + Array.from(this.relays) + .filter((r) => !r.connected) + .map((r) => r.connect()), + ).then((v) => void 0); + } + close() { + if (this.state !== MultiSubscription.OPEN) return this; + + // forget all seen events + this.forgetEvents(); + // unsubscribe from relay messages + this.state = MultiSubscription.CLOSED; + this.process.active = false; + + // close all + this.updateSubscriptions(); + + return this; + } + forgetEvents() { + // forget all seen events + this.seenEvents.clear(); + } + + destroy() { + this.process.remove(); + processManager.unregisterProcess(this.process); + } +} diff --git a/src/classes/nostr-multi-subscription.ts b/src/classes/nostr-multi-subscription.ts deleted file mode 100644 index 36c9cfb88..000000000 --- a/src/classes/nostr-multi-subscription.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { nanoid } from "nanoid"; - -import { NostrEvent } from "../types/nostr-event"; -import relayPoolService from "../services/relay-pool"; -import { isFilterEqual } from "../helpers/nostr/filter"; -import ControlledObservable from "./controlled-observable"; -import { AbstractRelay, Filter, Subscription } from "nostr-tools"; -import { offlineMode } from "../services/offline-mode"; -import RelaySet from "./relay-set"; - -export default class NostrMultiSubscription { - static INIT = "initial"; - static OPEN = "open"; - static CLOSED = "closed"; - - id: string; - name?: string; - filters: Filter[] = []; - - relays: AbstractRelay[] = []; - subscriptions = new Map(); - - state = NostrMultiSubscription.INIT; - onEvent = new ControlledObservable(); - seenEvents = new Set(); - - constructor(name?: string) { - this.id = nanoid(); - this.name = name; - } - private handleEvent(event: NostrEvent) { - if (this.seenEvents.has(event.id)) return; - this.onEvent.next(event); - this.seenEvents.add(event.id); - } - - private handleAddRelay(relay: AbstractRelay) { - relayPoolService.addClaim(relay.url, this); - } - private handleRemoveRelay(relay: AbstractRelay) { - relayPoolService.removeClaim(relay.url, this); - - // close subscription - const sub = this.subscriptions.get(relay); - if (sub && !sub.closed) { - sub.close(); - this.subscriptions.delete(relay); - } - } - - setFilters(filters: Filter[]) { - if (isFilterEqual(this.filters, filters)) return; - this.filters = filters; - this.updateSubscriptions(); - } - - setRelays(relays: Iterable) { - // add and remove relays - for (const url of relays) { - if (!this.relays.some((r) => r.url === url)) { - // add relay - const relay = relayPoolService.requestRelay(url); - this.relays.push(relay); - this.handleAddRelay(relay); - } - } - - const relaySet = RelaySet.from(relays); - for (const relay of this.relays) { - if (!relaySet.has(relay.url)) { - this.relays = this.relays.filter((r) => r !== relay); - this.handleRemoveRelay(relay); - } - } - - this.updateSubscriptions(); - } - - 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 filters = this.filters; - - let subscription = this.subscriptions.get(relay); - if (!subscription || !isFilterEqual(subscription.filters, filters)) { - if (subscription) { - subscription.filters = filters; - subscription.fire(); - } else { - if (!relay.connected) relayPoolService.requestConnect(relay); - - 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); - } - } - } - } - - publish(event: NostrEvent) { - return Promise.allSettled( - this.relays.map(async (r) => { - if (!r.connected) await relayPoolService.requestConnect(r); - return await r.publish(event); - }), - ); - } - - open() { - if (this.state === NostrMultiSubscription.OPEN) return this; - - this.state = NostrMultiSubscription.OPEN; - // reconnect to all relays - for (const relay of this.relays) this.handleAddRelay(relay); - // send queries - this.updateSubscriptions(); - - return this; - } - waitForAllConnection(): Promise { - if (offlineMode.value) return Promise.resolve(); - return Promise.allSettled(this.relays.filter((r) => !r.connected).map((r) => r.connect())).then((v) => void 0); - } - close() { - if (this.state !== NostrMultiSubscription.OPEN) return this; - - // forget all seen events - this.forgetEvents(); - // unsubscribe from relay messages - for (const relay of this.relays) this.handleRemoveRelay(relay); - // set state - this.state = NostrMultiSubscription.CLOSED; - - return this; - } - forgetEvents() { - // forget all seen events - this.seenEvents.clear(); - } -} diff --git a/src/classes/nostr-publish-action.ts b/src/classes/nostr-publish-action.ts index 3b9d62345..ad00be57c 100644 --- a/src/classes/nostr-publish-action.ts +++ b/src/classes/nostr-publish-action.ts @@ -10,7 +10,7 @@ import dayjs from "dayjs"; export type PublishResult = { relay: AbstractRelay; success: boolean; message: string }; export default class PublishAction { - id = nanoid(); + id = nanoid(8); label: string; relays: string[]; event: NostrEvent; diff --git a/src/classes/nostr-subscription.ts b/src/classes/nostr-subscription.ts index a92125211..08d0c7483 100644 --- a/src/classes/nostr-subscription.ts +++ b/src/classes/nostr-subscription.ts @@ -22,7 +22,7 @@ export default class NostrSubscription { onEOSE = new ControlledObservable(); constructor(relayUrl: string | URL, filters?: Filter[], name?: string) { - this.id = nanoid(); + this.id = nanoid(8); this.filters = filters; this.name = name; @@ -48,8 +48,6 @@ export default class NostrSubscription { oneose: () => this.onEOSE.next(Math.random()), }); - relayPoolService.addClaim(this.relay.url, this); - return this; } close() { @@ -59,8 +57,6 @@ export default class NostrSubscription { this.state = NostrSubscription.CLOSED; // send close message this.subscription?.close(); - // unsubscribe from relay messages - relayPoolService.removeClaim(this.relay.url, this); return this; } diff --git a/src/classes/persistent-subscription.ts b/src/classes/persistent-subscription.ts new file mode 100644 index 000000000..11e5a9229 --- /dev/null +++ b/src/classes/persistent-subscription.ts @@ -0,0 +1,85 @@ +import { nanoid } from "nanoid"; +import { AbstractRelay, Filter, Relay, Subscription, SubscriptionParams } from "nostr-tools"; + +import relayPoolService from "../services/relay-pool"; +import Process from "./process"; +import FilterFunnel01 from "../components/icons/filter-funnel-01"; +import processManager from "../services/process-manager"; + +export default class PersistentSubscription { + id: string; + process: Process; + relay: Relay; + filters: Filter[]; + closed = true; + eosed = false; + params: Partial; + + subscription: Subscription | null = null; + + constructor(relay: AbstractRelay, params?: Partial) { + this.id = nanoid(8); + this.process = new Process("PersistentSubscription", this, [relay]); + this.process.icon = FilterFunnel01; + this.filters = []; + this.params = { + //@ts-expect-error + id: this.id, + ...params, + }; + + this.relay = relay; + + processManager.registerProcess(this.process); + } + + async fire() { + if (!this.filters || this.filters.length === 0) return this; + + if (!(await relayPoolService.waitForOpen(this.relay))) return; + + this.closed = false; + this.process.active = true; + + // recreate the subscription if its closed since nostr-tools cant reopen a sub + if (!this.subscription || this.subscription.closed) { + this.subscription = this.relay.subscribe(this.filters, { + ...this.params, + oneose: () => { + this.eosed = true; + this.params.oneose?.(); + }, + onclose: (reason) => { + if (!this.closed) { + // unexpected close, reconnect? + console.log("Unexpected closed", this.relay, reason); + + this.closed = true; + this.process.active = false; + } + this.params.onclose?.(reason); + }, + }); + } + + this.subscription.filters = this.filters; + this.subscription.fire(); + + return this; + } + close() { + if (this.closed) return this; + + this.closed = true; + if (this.subscription?.closed === false) this.subscription.close(); + this.process.active = false; + + return this; + } + + destroy() { + this.close(); + this.process.remove(); + processManager.unregisterProcess(this.process); + } +} diff --git a/src/classes/process.ts b/src/classes/process.ts new file mode 100644 index 000000000..0c438587b --- /dev/null +++ b/src/classes/process.ts @@ -0,0 +1,53 @@ +import { ComponentWithAs, IconProps } from "@chakra-ui/react"; +import { SimpleRelay } from "nostr-idb"; +import { AbstractRelay } from "nostr-tools"; + +let lastId = 0; +export default class Process { + id = ++lastId; + type: string; + name?: string; + icon?: ComponentWithAs<"svg", IconProps>; + source: any; + + // if this process is running + active: boolean = false; + + // the relays this process is claiming + relays = new Set(); + + // the parent process + parent?: Process; + // any children this process has created + children = new Set(); + + constructor(type: string, source: any, relays?: Iterable) { + this.type = type; + this.source = source; + + this.relays = new Set(relays); + } + + static forkOrCreate(name: string, source: any, relays: Iterable, parent?: Process) { + return parent?.fork(name, source, relays) || new Process("BatchKindLoader", this, relays); + } + + addChild(child: Process) { + if (child === this) throw new Error("Process cant be a child of itself"); + this.children.add(child); + child.parent = this; + } + + fork(name: string, source: any, relays?: Iterable) { + const child = new Process(name, source, relays); + this.addChild(child); + return child; + } + + remove() { + if (!this.parent) return; + + this.parent.children.delete(this); + this.parent = undefined; + } +} diff --git a/src/classes/relay-pool.ts b/src/classes/relay-pool.ts index 572d73710..694e25606 100644 --- a/src/classes/relay-pool.ts +++ b/src/classes/relay-pool.ts @@ -1,29 +1,47 @@ import { AbstractRelay } from "nostr-tools"; import { logger } from "../helpers/debug"; -import { validateRelayURL } from "../helpers/relay"; +import { safeRelayUrl, validateRelayURL } from "../helpers/relay"; import { offlineMode } from "../services/offline-mode"; -import Subject from "./subject"; +import Subject, { PersistentSubject } from "./subject"; import verifyEventMethod from "../services/verify-event"; +import SuperMap from "./super-map"; +import processManager from "../services/process-manager"; export default class RelayPool { relays = new Map(); onRelayCreated = new Subject(); + onRelayChallenge = new Subject<[AbstractRelay, string]>(); - relayClaims = new Map>(); + connectionErrors = new SuperMap(() => []); + connecting = new SuperMap>(() => new PersistentSubject(false)); log = logger.extend("RelayPool"); - getRelays() { - return Array.from(this.relays.values()); + getRelay(relayOrUrl: string | URL | AbstractRelay) { + let relay: AbstractRelay | undefined = undefined; + + if (typeof relayOrUrl === "string") { + const safeURL = safeRelayUrl(relayOrUrl); + if (safeURL) relay = this.relays.get(safeURL) || this.requestRelay(safeURL); + } else if (relayOrUrl instanceof URL) { + relay = this.relays.get(relayOrUrl.toString()) || this.requestRelay(relayOrUrl.toString()); + } else relay = relayOrUrl; + + return relay; } - getRelayClaims(url: string | URL) { - url = validateRelayURL(url); - const key = url.toString(); - if (!this.relayClaims.has(key)) { - this.relayClaims.set(key, new Set()); + + getRelays(urls?: Iterable) { + if (urls) { + const relays: AbstractRelay[] = []; + for (const url of urls) { + const relay = this.getRelay(url); + if (relay) relays.push(relay); + } + return relays; } - return this.relayClaims.get(key) as Set; + + return Array.from(this.relays.values()); } requestRelay(url: string | URL, connect = true) { @@ -32,6 +50,7 @@ export default class RelayPool { const key = url.toString(); if (!this.relays.has(key)) { const newRelay = new AbstractRelay(key, { verifyEvent: verifyEventMethod }); + newRelay._onauth = (challenge) => this.onRelayChallenge.next([newRelay, challenge]); this.relays.set(key, newRelay); this.onRelayCreated.next(newRelay); } @@ -41,67 +60,58 @@ export default class RelayPool { return relay; } - async requestConnect(relayOrUrl: string | URL | AbstractRelay) { - let relay: AbstractRelay | undefined = undefined; + async waitForOpen(relayOrUrl: string | URL | AbstractRelay, quite = true) { + let relay = this.getRelay(relayOrUrl); + if (!relay) return Promise.reject("Missing relay"); - if (typeof relayOrUrl === "string") relay = this.relays.get(relayOrUrl); - else if (relayOrUrl instanceof URL) relay = this.relays.get(relayOrUrl.toString()); - else relay = relayOrUrl; + if (relay.connected) return true; + try { + // if the relay is connecting, wait. otherwise request a connection + // @ts-expect-error + (await relay.connectionPromise) || this.requestConnect(relay, quite); + return true; + } catch (err) { + if (quite) return false; + else throw err; + } + } + + async requestConnect(relayOrUrl: string | URL | AbstractRelay, quite = true) { + let relay = this.getRelay(relayOrUrl); if (!relay) return; if (!relay.connected && !offlineMode.value) { + this.connecting.get(relay).next(true); try { - relay.connect(); + await relay.connect(); + this.connecting.get(relay).next(false); } catch (e) { - this.log(`Failed to connect to ${relay.url}`); - this.log(e); + e = e || new Error("Unknown error"); + if (e instanceof Error) { + this.log(`Failed to connect to ${relay.url}`, e.message); + this.connectionErrors.get(relay).push(e); + } + this.connecting.get(relay).next(false); + if (!quite) throw e; } } } - pruneRelays() { - for (const [url, relay] of this.relays.entries()) { - const claims = this.getRelayClaims(url).size; - if (claims === 0) { + disconnectFromUnused() { + for (const [url, relay] of this.relays) { + let disconnect = true; + for (const process of processManager.processes) { + if (process.active && process.relays.has(relay)) { + disconnect = false; + break; + } + } + + if (disconnect) { + this.log(`No active processes using ${relay.url}, disconnecting`); relay.close(); } } } - reconnectRelays() { - if (offlineMode.value) return; - - for (const [url, relay] of this.relays.entries()) { - const claims = this.getRelayClaims(url).size; - if (!relay.connected && claims > 0) { - try { - relay.connect(); - } catch (e) { - this.log(`Failed to connect to ${relay.url}`); - this.log(e); - } - } - } - } - - addClaim(relay: string | URL, id: any) { - try { - const key = validateRelayURL(relay).toString(); - this.getRelayClaims(key).add(id); - } catch (error) {} - } - removeClaim(relay: string | URL, id: any) { - try { - const key = validateRelayURL(relay).toString(); - this.getRelayClaims(key).delete(id); - } catch (error) {} - } - - get connectedCount() { - let count = 0; - for (const [url, relay] of this.relays.entries()) { - if (relay.connected) count++; - } - return count; - } } diff --git a/src/classes/super-map.ts b/src/classes/super-map.ts index f02b977c2..f13ef299a 100644 --- a/src/classes/super-map.ts +++ b/src/classes/super-map.ts @@ -6,9 +6,6 @@ export default class SuperMap extends Map { this.newValue = newValue; } - has(key: Key) { - return true; - } get(key: Key) { let value = super.get(key); if (value === undefined) { diff --git a/src/classes/timeline-loader.ts b/src/classes/timeline-loader.ts index e335477c0..547946855 100644 --- a/src/classes/timeline-loader.ts +++ b/src/classes/timeline-loader.ts @@ -1,9 +1,9 @@ import dayjs from "dayjs"; import { Debugger } from "debug"; -import { Filter, NostrEvent } from "nostr-tools"; +import { AbstractRelay, Filter, NostrEvent } from "nostr-tools"; import _throttle from "lodash.throttle"; -import NostrMultiSubscription from "./nostr-multi-subscription"; +import MultiSubscription from "./multi-subscription"; import { PersistentSubject } from "./subject"; import { logger } from "../helpers/debug"; import EventStore from "./event-store"; @@ -14,6 +14,9 @@ import { localRelay } from "../services/local-relay"; import SuperMap from "./super-map"; import ChunkedRequest from "./chunked-request"; import relayPoolService from "../services/relay-pool"; +import Process from "./process"; +import AlignHorizontalCentre02 from "../components/icons/align-horizontal-centre-02"; +import processManager from "../services/process-manager"; const BLOCK_SIZE = 100; @@ -22,7 +25,7 @@ export type EventFilter = (event: NostrEvent, store: EventStore) => boolean; export default class TimelineLoader { cursor = dayjs().unix(); filters: Filter[] = []; - relays: string[] = []; + relays: AbstractRelay[] = []; events: EventStore; timeline = new PersistentSubject([]); @@ -33,25 +36,33 @@ export default class TimelineLoader { eventFilter?: EventFilter; name: string; + process: Process; private log: Debugger; - private subscription: NostrMultiSubscription; + private subscription: MultiSubscription; private cacheChunkLoader: ChunkedRequest | null = null; private chunkLoaders = new Map(); constructor(name: string) { this.name = name; + this.process = new Process("TimelineLoader", this); + this.process.name = name; + this.process.icon = AlignHorizontalCentre02; + this.log = logger.extend("TimelineLoader:" + name); this.events = new EventStore(name); this.events.connect(replaceableEventsService.events, false); - this.subscription = new NostrMultiSubscription(name); + this.subscription = new MultiSubscription(name); this.subscription.onEvent.subscribe(this.handleEvent.bind(this)); + this.process.addChild(this.subscription.process); // update the timeline when there are new events this.events.onEvent.subscribe(this.throttleUpdateTimeline.bind(this)); this.events.onDelete.subscribe(this.throttleUpdateTimeline.bind(this)); this.events.onClear.subscribe(this.throttleUpdateTimeline.bind(this)); + + processManager.registerProcess(this.process); } private throttleUpdateTimeline = _throttle(this.updateTimeline, 10); @@ -75,12 +86,14 @@ export default class TimelineLoader { private chunkLoaderSubs = new SuperMap(() => []); private connectToChunkLoader(loader: ChunkedRequest) { + this.process.addChild(loader.process); + this.events.connect(loader.events); const subs = this.chunkLoaderSubs.get(loader); subs.push(loader.onChunkFinish.subscribe(this.handleChunkFinished.bind(this))); } - private disconnectToChunkLoader(loader: ChunkedRequest) { - loader.cleanup(); + private disconnectFromChunkLoader(loader: ChunkedRequest) { + loader.destroy(); this.events.disconnect(loader.events); const subs = this.chunkLoaderSubs.get(loader); for (const sub of subs) sub.unsubscribe(); @@ -93,15 +106,19 @@ export default class TimelineLoader { this.log("Set filters", filters); // recreate all chunk loaders - for (const url of this.relays) { - const loader = this.chunkLoaders.get(url); + for (const relay of this.relays) { + const loader = this.chunkLoaders.get(relay.url); if (loader) { - this.disconnectToChunkLoader(loader); - this.chunkLoaders.delete(url); + this.disconnectFromChunkLoader(loader); + this.chunkLoaders.delete(relay.url); } - const chunkLoader = new ChunkedRequest(relayPoolService.requestRelay(url), filters, this.log.extend(url)); - this.chunkLoaders.set(url, chunkLoader); + const chunkLoader = new ChunkedRequest( + relayPoolService.requestRelay(relay.url), + filters, + this.log.extend(relay.url), + ); + this.chunkLoaders.set(relay.url, chunkLoader); this.connectToChunkLoader(chunkLoader); } @@ -109,9 +126,9 @@ export default class TimelineLoader { this.filters = filters; // recreate cache chunk loader - if (this.cacheChunkLoader) this.disconnectToChunkLoader(this.cacheChunkLoader); + if (this.cacheChunkLoader) this.disconnectFromChunkLoader(this.cacheChunkLoader); if (localRelay) { - this.cacheChunkLoader = new ChunkedRequest(localRelay, this.filters, this.log.extend("local-relay")); + this.cacheChunkLoader = new ChunkedRequest(localRelay, this.filters, this.log.extend("cache-relay")); this.connectToChunkLoader(this.cacheChunkLoader); } @@ -119,32 +136,35 @@ export default class TimelineLoader { this.subscription.setFilters(mergeFilter(filters, { limit: BLOCK_SIZE / 2 })); } - setRelays(relays: Iterable) { - this.relays = Array.from(relays); + setRelays(relays: Iterable) { + const newRelays = relayPoolService.getRelays(relays); // remove chunk loaders - for (const url of relays) { - const loader = this.chunkLoaders.get(url); + for (const relay of newRelays) { + const loader = this.chunkLoaders.get(relay.url); if (!loader) continue; - if (!this.relays.includes(url)) { - this.disconnectToChunkLoader(loader); - this.chunkLoaders.delete(url); + if (!this.relays.includes(relay)) { + this.disconnectFromChunkLoader(loader); + this.chunkLoaders.delete(relay.url); } } // create chunk loaders only if filters are set if (this.filters.length > 0) { - for (const url of relays) { - if (!this.chunkLoaders.has(url)) { - const loader = new ChunkedRequest(relayPoolService.requestRelay(url), this.filters, this.log.extend(url)); - this.chunkLoaders.set(url, loader); + for (const relay of newRelays) { + if (!this.chunkLoaders.has(relay.url)) { + const loader = new ChunkedRequest(relay, this.filters, this.log.extend(relay.url)); + this.chunkLoaders.set(relay.url, loader); this.connectToChunkLoader(loader); } } } + this.relays = relayPoolService.getRelays(relays); + this.process.relays = new Set(this.relays); + // update live subscription - this.subscription.setRelays(relays); + this.subscription.setRelays(this.relays); } setEventFilter(filter?: EventFilter) { @@ -219,9 +239,11 @@ export default class TimelineLoader { return this.complete.next(true); } open() { + this.process.active = true; this.subscription.open(); } close() { + this.process.active = false; this.subscription.close(); } @@ -233,21 +255,25 @@ export default class TimelineLoader { reset() { this.cursor = dayjs().unix(); const loaders = this.getAllLoaders(); - for (const loader of loaders) this.disconnectToChunkLoader(loader); + for (const loader of loaders) this.disconnectFromChunkLoader(loader); this.chunkLoaders.clear(); this.cacheChunkLoader = null; this.forgetEvents(); } /** close the subscription and remove any event listeners for this timeline */ - cleanup() { + destroy() { this.close(); const loaders = this.getAllLoaders(); - for (const loader of loaders) this.disconnectToChunkLoader(loader); + for (const loader of loaders) this.disconnectFromChunkLoader(loader); this.chunkLoaders.clear(); this.cacheChunkLoader = null; + this.subscription.destroy(); + this.events.cleanup(); + this.process.remove(); + processManager.unregisterProcess(this.process); } } diff --git a/src/components/charts/event-kinds-table.tsx b/src/components/charts/event-kinds-table.tsx index 81dc9f938..7b4a508a5 100644 --- a/src/components/charts/event-kinds-table.tsx +++ b/src/components/charts/event-kinds-table.tsx @@ -18,7 +18,7 @@ export default function EventKindsTable({ ); return ( - + diff --git a/src/components/external-embeds/types/cashu.tsx b/src/components/external-embeds/types/cashu.tsx index 7fd76f83f..4dcce4563 100644 --- a/src/components/external-embeds/types/cashu.tsx +++ b/src/components/external-embeds/types/cashu.tsx @@ -8,7 +8,7 @@ export function embedCashuTokens(content: EmbedableContent) { return embedJSX(content, { regexp: getMatchCashu(), render: (match) => { - // set zIndex and position so link over dose not cover card + // set zIndex and position so link over does not cover card return ; }, name: "emoji", diff --git a/src/components/external-embeds/types/nostr.tsx b/src/components/external-embeds/types/nostr.tsx index f4c3ed421..f5c93c63a 100644 --- a/src/components/external-embeds/types/nostr.tsx +++ b/src/components/external-embeds/types/nostr.tsx @@ -73,7 +73,7 @@ export function embedNostrHashtags(content: EmbedableContent, event: NostrEvent name: "nostr-hashtag", regexp: getMatchHashtag(), getLocation: (match) => { - if (match.index === undefined) throw new Error("match dose not have index"); + if (match.index === undefined) throw new Error("match does not have index"); const start = match.index + match[1].length; const end = start + 1 + match[2].length; diff --git a/src/components/layout/task-manager-button.tsx b/src/components/layout/task-manager-button.tsx index 8cdf5bb87..9d04fadc3 100644 --- a/src/components/layout/task-manager-button.tsx +++ b/src/components/layout/task-manager-button.tsx @@ -10,7 +10,12 @@ export default function TaskManagerButton({ ...props }: Omit openTaskManager("/network")} {...props}> + diff --git a/src/components/relay-status.tsx b/src/components/relay-status.tsx index 59a74d552..bea64d9c5 100644 --- a/src/components/relay-status.tsx +++ b/src/components/relay-status.tsx @@ -3,17 +3,18 @@ import { useInterval } from "react-use"; import relayPoolService from "../services/relay-pool"; import { AbstractRelay } from "nostr-tools"; +import useSubject from "../hooks/use-subject"; -const getStatusText = (relay: AbstractRelay) => { - // if (relay.connecting) return "Connecting..."; +const getStatusText = (relay: AbstractRelay, connecting = false) => { + if (connecting) return "Connecting..."; if (relay.connected) return "Connected"; // if (relay.closing) return "Disconnecting..."; // if (relay.closed) return "Disconnected"; return "Disconnected"; // return "Unused"; }; -const getStatusColor = (relay: AbstractRelay) => { - // if (relay.connecting) return "yellow"; +const getStatusColor = (relay: AbstractRelay, connecting = false) => { + if (connecting) return "yellow"; if (relay.connected) return "green"; // if (relay.closing) return "yellow"; // if (relay.closed) return "red"; @@ -25,8 +26,9 @@ export const RelayStatus = ({ url }: { url: string }) => { const update = useForceUpdate(); const relay = relayPoolService.requestRelay(url, false); + const connecting = useSubject(relayPoolService.connecting.get(relay)); useInterval(() => update(), 500); - return {getStatusText(relay)}; + return {getStatusText(relay, connecting)}; }; diff --git a/src/components/relays/relay-auth-button.tsx b/src/components/relays/relay-auth-button.tsx new file mode 100644 index 000000000..e9adb1a5a --- /dev/null +++ b/src/components/relays/relay-auth-button.tsx @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from "react"; +import { AbstractRelay } from "nostr-tools"; +import { Button, useToast } from "@chakra-ui/react"; + +import relayPoolService from "../../services/relay-pool"; +import { useSigningContext } from "../../providers/global/signing-provider"; + +export default function RelayAuthButton({ relay }: { relay: string | URL | AbstractRelay }) { + const toast = useToast(); + const { requestSignature } = useSigningContext(); + const r = relayPoolService.getRelay(relay); + if (!r) return null; + + // @ts-expect-error + const [challenge, setChallenge] = useState(r.challenge ?? ""); + useEffect(() => { + const sub = relayPoolService.onRelayChallenge.subscribe(([relay, challenge]) => { + if (r === relay) setChallenge(challenge); + }); + + return () => sub.unsubscribe(); + }, [r]); + + const [loading, setLoading] = useState(false); + const auth = useCallback(async () => { + setLoading(true); + try { + const message = await r.auth(requestSignature); + toast({ description: message || "Success", status: "success" }); + } catch (error) { + if (error instanceof Error) toast({ status: "error", description: error.message }); + } + setLoading(false); + }, [r, requestSignature]); + + if (challenge) + return ( + + ); + return null; +} diff --git a/src/helpers/embeds.ts b/src/helpers/embeds.ts index 7ca5752ba..e023fdb24 100644 --- a/src/helpers/embeds.ts +++ b/src/helpers/embeds.ts @@ -10,7 +10,7 @@ export type EmbedType = { }; export function defaultGetLocation(match: RegExpMatchArray) { - if (match.index === undefined) throw new Error("match dose not have index"); + if (match.index === undefined) throw new Error("match does not have index"); return { start: match.index, end: match.index + match[0].length, diff --git a/src/helpers/nostr/event.ts b/src/helpers/nostr/event.ts index ee71ca976..e387d44a8 100644 --- a/src/helpers/nostr/event.ts +++ b/src/helpers/nostr/event.ts @@ -10,11 +10,9 @@ import { safeJson } from "../parse"; import { safeDecode } from "../nip19"; import { safeRelayUrl, safeRelayUrls } from "../relay"; import RelaySet from "../../classes/relay-set"; +import { truncateId } from "../string"; -export function truncatedId(str: string, keep = 6) { - if (str.length < keep * 2 + 3) return str; - return str.substring(0, keep) + "..." + str.substring(str.length - keep); -} +export { truncateId as truncatedId }; export function isReplaceable(kind: number) { return kinds.isReplaceableKind(kind) || kinds.isParameterizedReplaceableKind(kind); @@ -124,7 +122,7 @@ export function interpretThreadTags(event: NostrEvent | DraftNostrEvent) { let replyATag = aTags.find((t) => t[3] === "reply"); if (!rootETag || !replyETag) { - // a direct reply dose not need a "reply" reference + // a direct reply does not need a "reply" reference // https://github.com/nostr-protocol/nips/blob/master/10.md // this is not necessarily to spec. but if there is only one id (root or reply) then assign it to both diff --git a/src/helpers/relay.ts b/src/helpers/relay.ts index 0b4bc2577..5439ffba6 100644 --- a/src/helpers/relay.ts +++ b/src/helpers/relay.ts @@ -97,6 +97,12 @@ export function splitQueryByPubkeys(query: NostrQuery, relayPubkeyMap: Record((res) => { const events: NostrEvent[] = []; diff --git a/src/helpers/string.ts b/src/helpers/string.ts index 7512da4fa..4a7d884f8 100644 --- a/src/helpers/string.ts +++ b/src/helpers/string.ts @@ -1,3 +1,8 @@ export function removeNonASCIIChar(str: string) { return str.replaceAll(/Entry Name/g, ""); } + +export function truncateId(str: string, keep = 4) { + if (str.length < keep * 2 + 3) return str; + return str.substring(0, keep) + "…" + str.substring(str.length - keep); +} diff --git a/src/hooks/use-favorite-lists.ts b/src/hooks/use-favorite-lists.ts index 04f6b54fe..7c88ce930 100644 --- a/src/hooks/use-favorite-lists.ts +++ b/src/hooks/use-favorite-lists.ts @@ -11,8 +11,6 @@ export default function useFavoriteLists(pubkey?: string) { const favoriteList = useReplaceableEvent( key ? { kind: 30078, pubkey: key, identifier: FAVORITE_LISTS_IDENTIFIER } : undefined, - [], - { ignoreCache: true }, ); const lists = useReplaceableEvents(favoriteList ? getCoordinatesFromList(favoriteList).map((a) => a.coordinate) : []); diff --git a/src/hooks/use-user-lists.ts b/src/hooks/use-user-lists.ts index ba56d7cfa..262a893a4 100644 --- a/src/hooks/use-user-lists.ts +++ b/src/hooks/use-user-lists.ts @@ -5,6 +5,7 @@ import { useReadRelays } from "./use-client-relays"; import useSubject from "./use-subject"; import useTimelineLoader from "./use-timeline-loader"; import { NostrEvent } from "../types/nostr-event"; +import { truncateId } from "../helpers/string"; export default function useUserLists(pubkey?: string, additionalRelays?: Iterable) { const readRelays = useReadRelays(additionalRelays); @@ -12,7 +13,7 @@ export default function useUserLists(pubkey?: string, additionalRelays?: Iterabl return !isJunkList(event); }, []); const timeline = useTimelineLoader( - `${pubkey}-lists`, + `${truncateId(pubkey ?? "anon")}-lists`, readRelays, pubkey ? { diff --git a/src/hooks/use-user-metadata.ts b/src/hooks/use-user-metadata.ts index 7346fb20d..07c8184ad 100644 --- a/src/hooks/use-user-metadata.ts +++ b/src/hooks/use-user-metadata.ts @@ -10,11 +10,11 @@ export default function useUserMetadata( additionalRelays: Iterable = [], opts: RequestOptions = {}, ) { - const relays = useReadRelays([...additionalRelays, COMMON_CONTACT_RELAY]); + const readRelays = useReadRelays([...additionalRelays, COMMON_CONTACT_RELAY]); const subject = useMemo( - () => (pubkey ? userMetadataService.requestMetadata(pubkey, relays, opts) : undefined), - [pubkey, relays], + () => (pubkey ? userMetadataService.requestMetadata(pubkey, readRelays, opts) : undefined), + [pubkey, readRelays], ); const metadata = useSubject(subject); diff --git a/src/hooks/use-user-relay-sets.ts b/src/hooks/use-user-relay-sets.ts index 81ed257a7..670ca0db0 100644 --- a/src/hooks/use-user-relay-sets.ts +++ b/src/hooks/use-user-relay-sets.ts @@ -5,12 +5,13 @@ import { useReadRelays } from "./use-client-relays"; import useSubject from "./use-subject"; import useTimelineLoader from "./use-timeline-loader"; import { NostrEvent, isRTag } from "../types/nostr-event"; +import { truncateId } from "../helpers/string"; export default function useUserRelaySets(pubkey?: string, additionalRelays?: Iterable) { const readRelays = useReadRelays(additionalRelays); const eventFilter = useCallback((event: NostrEvent) => event.tags.some(isRTag), []); const timeline = useTimelineLoader( - `${pubkey}-relay-sets`, + `${truncateId(pubkey || "anon")}-relay-sets`, readRelays, pubkey ? { diff --git a/src/providers/global/dm-timeline.tsx b/src/providers/global/dms-provider.tsx similarity index 94% rename from src/providers/global/dm-timeline.tsx rename to src/providers/global/dms-provider.tsx index dae12e516..fe2857e99 100644 --- a/src/providers/global/dm-timeline.tsx +++ b/src/providers/global/dms-provider.tsx @@ -7,6 +7,7 @@ import { NostrEvent } from "../../types/nostr-event"; import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter"; import useTimelineLoader from "../../hooks/use-timeline-loader"; import { useUserInbox } from "../../hooks/use-user-mailboxes"; +import { truncateId } from "../../helpers/string"; type DMTimelineContextType = { timeline?: TimelineLoader; @@ -35,7 +36,7 @@ export default function DMTimelineProvider({ children }: PropsWithChildren) { ); const timeline = useTimelineLoader( - `${account?.pubkey ?? "anon"}-dms`, + `${truncateId(account?.pubkey ?? "anon")}-dms`, inbox, account?.pubkey ? [ diff --git a/src/providers/global/dycryption-provider.tsx b/src/providers/global/dycryption-provider.tsx index 04e206237..291e0724f 100644 --- a/src/providers/global/dycryption-provider.tsx +++ b/src/providers/global/dycryption-provider.tsx @@ -5,12 +5,9 @@ import Subject from "../../classes/subject"; import { useSigningContext } from "./signing-provider"; import useSubject from "../../hooks/use-subject"; import createDefer, { Deferred } from "../../classes/deferred"; -import { NostrEvent } from "../../types/nostr-event"; -import useCurrentAccount from "../../hooks/use-current-account"; -import { getDMRecipient, getDMSender } from "../../helpers/nostr/dms"; class DecryptionContainer { - id = nanoid(); + id = nanoid(8); pubkey: string; data: string; diff --git a/src/providers/global/index.tsx b/src/providers/global/index.tsx index bc2d1cfd1..cb40f9658 100644 --- a/src/providers/global/index.tsx +++ b/src/providers/global/index.tsx @@ -4,12 +4,12 @@ import { ChakraProvider, localStorageManager } from "@chakra-ui/react"; import { SigningProvider } from "./signing-provider"; import buildTheme from "../../theme"; import useAppSettings from "../../hooks/use-app-settings"; -import NotificationsProvider from "./notifications"; +import NotificationsProvider from "./notifications-provider"; import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-provider"; import { AllUserSearchDirectoryProvider } from "./user-directory-provider"; import BreakpointProvider from "./breakpoint-provider"; import DecryptionProvider from "./dycryption-provider"; -import DMTimelineProvider from "./dm-timeline"; +import DMTimelineProvider from "./dms-provider"; import PublishProvider from "./publish-provider"; // Top level providers, should be render as close to the root as possible diff --git a/src/providers/global/notifications.tsx b/src/providers/global/notifications-provider.tsx similarity index 95% rename from src/providers/global/notifications.tsx rename to src/providers/global/notifications-provider.tsx index a2151c5dc..11927ffd1 100644 --- a/src/providers/global/notifications.tsx +++ b/src/providers/global/notifications-provider.tsx @@ -10,6 +10,7 @@ import useTimelineLoader from "../../hooks/use-timeline-loader"; import { TORRENT_COMMENT_KIND } from "../../helpers/nostr/torrents"; import { useUserInbox } from "../../hooks/use-user-mailboxes"; import AccountNotifications from "../../classes/notifications"; +import { truncateId } from "../../helpers/string"; type NotificationTimelineContextType = { timeline: TimelineLoader; @@ -40,7 +41,7 @@ export default function NotificationsProvider({ children }: PropsWithChildren) { ); const timeline = useTimelineLoader( - `${account?.pubkey ?? "anon"}-notification`, + `${truncateId(account?.pubkey ?? "anon")}-notification`, readRelays, account?.pubkey ? { diff --git a/src/providers/global/signing-provider.tsx b/src/providers/global/signing-provider.tsx index c06cb51c2..d6df1dbe8 100644 --- a/src/providers/global/signing-provider.tsx +++ b/src/providers/global/signing-provider.tsx @@ -3,10 +3,11 @@ import React, { useCallback, useContext, useMemo } from "react"; import useSubject from "../../hooks/use-subject"; import accountService from "../../services/account"; import signingService from "../../services/signing"; -import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event"; +import { DraftNostrEvent } from "../../types/nostr-event"; +import { EventTemplate, VerifiedEvent } from "nostr-tools"; export type SigningContextType = { - requestSignature: (draft: DraftNostrEvent) => Promise; + requestSignature: (draft: EventTemplate | DraftNostrEvent) => Promise; requestDecrypt: (data: string, pubkey: string) => Promise; requestEncrypt: (data: string, pubkey: string) => Promise; }; diff --git a/src/services/amber-signer.ts b/src/services/amber-signer.ts index 5b5726626..8974c9140 100644 --- a/src/services/amber-signer.ts +++ b/src/services/amber-signer.ts @@ -1,8 +1,9 @@ -import { getEventHash, nip19, verifyEvent } from "nostr-tools"; +import { VerifiedEvent, getEventHash, nip19 } from "nostr-tools"; import createDefer, { Deferred } from "../classes/deferred"; import { getPubkeyFromDecodeResult, isHex, isHexKey } from "../helpers/nip19"; import { DraftNostrEvent, NostrEvent } from "../types/nostr-event"; +import { alwaysVerify } from "./verify-event"; export function createGetPublicKeyIntent() { return `intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end`; @@ -72,13 +73,13 @@ async function getPublicKey() { throw new Error("Expected clipboard to have pubkey"); } -async function signEvent(draft: DraftNostrEvent & { pubkey: string }): Promise { +async function signEvent(draft: DraftNostrEvent & { pubkey: string }): Promise { const draftWithId = { ...draft, id: draft.id || getEventHash(draft) }; const sig = await intentRequest(createSignEventIntent(draftWithId)); if (!isHex(sig)) throw new Error("Expected hex signature"); const event: NostrEvent = { ...draftWithId, sig }; - if (!verifyEvent(event)) throw new Error("Invalid signature"); + if (!alwaysVerify(event)) throw new Error("Invalid signature"); return event; } diff --git a/src/services/client-relays.ts b/src/services/client-relays.ts index a44fba0a5..66896c47c 100644 --- a/src/services/client-relays.ts +++ b/src/services/client-relays.ts @@ -62,7 +62,7 @@ class ClientRelayService { } setRelaysFromRelaySet(event: NostrEvent) { this.writeRelays.next(RelaySet.fromNIP65Event(event, RelayMode.WRITE)); - this.readRelays.next(RelaySet.fromNIP65Event(event, RelayMode.READ)); + this.readRelays.next(RelaySet.fromNIP65Event(event, RelayMode.ALL)); this.saveRelays(); } diff --git a/src/services/delete-events.ts b/src/services/delete-events.ts index dce09877a..bd38c0bb1 100644 --- a/src/services/delete-events.ts +++ b/src/services/delete-events.ts @@ -10,7 +10,7 @@ function handleEvent(deleteEvent: NostrEvent) { deleteEventStream.next(deleteEvent); } -function doseMatch(deleteEvent: NostrEvent, event: NostrEvent) { +function doesMatch(deleteEvent: NostrEvent, event: NostrEvent) { const id = getEventUID(event); return deleteEvent.tags.some((t) => (t[0] === "a" || t[0] === "e") && t[1] === id); } @@ -18,7 +18,7 @@ function doseMatch(deleteEvent: NostrEvent, event: NostrEvent) { const deleteEventService = { stream: deleteEventStream, handleEvent, - doseMatch, + doesMatch, }; export default deleteEventService; diff --git a/src/services/event-exists.ts b/src/services/event-exists.ts index faf11f35a..f28cbbc83 100644 --- a/src/services/event-exists.ts +++ b/src/services/event-exists.ts @@ -87,8 +87,8 @@ class EventExistsService { handleEvent(event: NostrEvent, cache = true) { for (const [key, filter] of this.filters) { - const doseMatch = Array.isArray(filter) ? matchFilters(filter, event) : matchFilter(filter, event); - if (doseMatch && this.answers.get(key).value !== true) { + const doesMatch = Array.isArray(filter) ? matchFilters(filter, event) : matchFilter(filter, event); + if (doesMatch && this.answers.get(key).value !== true) { this.answers.get(key).next(true); } } diff --git a/src/services/event-zaps.ts b/src/services/event-zaps.ts index fcff5c33e..c57565758 100644 --- a/src/services/event-zaps.ts +++ b/src/services/event-zaps.ts @@ -66,7 +66,7 @@ class EventZapsService { } } - for (const [relay, ids] of Object.entries(idsFromRelays)) { + for (const [url, ids] of Object.entries(idsFromRelays)) { const eventIds = ids.filter((id) => !id.includes(":")); const coordinates = ids.filter((id) => id.includes(":")); const filter: Filter[] = []; @@ -74,9 +74,15 @@ class EventZapsService { if (coordinates.length > 0) filter.push({ "#a": coordinates, kinds: [kinds.Zap] }); if (filter.length > 0) { - const sub = relayPoolService - .requestRelay(relay) - .subscribe(filter, { onevent: (event) => this.handleEvent(event), oneose: () => sub.close() }); + const relay = relayPoolService.getRelay(url); + if (relay) { + if (!relay.connected) relayPoolService.requestConnect(relay); + + const sub = 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 f58813493..3299e31c4 100644 --- a/src/services/nostr-connect.ts +++ b/src/services/nostr-connect.ts @@ -3,7 +3,7 @@ import dayjs from "dayjs"; import { nanoid } from "nanoid"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; -import NostrMultiSubscription from "../classes/nostr-multi-subscription"; +import MultiSubscription from "../classes/multi-subscription"; import { getPubkeyFromDecodeResult, isHexKey, normalizeToHexPubkey } from "../helpers/nip19"; import { logger } from "../helpers/debug"; import { DraftNostrEvent, NostrEvent, isPTag } from "../types/nostr-event"; @@ -11,6 +11,7 @@ import createDefer, { Deferred } from "../classes/deferred"; import { NostrConnectAccount } from "./account"; import { safeRelayUrl } from "../helpers/relay"; import { alwaysVerify } from "./verify-event"; +import { truncateId } from "../helpers/string"; export function isErrorResponse(response: any): response is NostrConnectErrorResponse { return !!response.error; @@ -60,7 +61,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; + sub: MultiSubscription; log = logger.extend("NostrConnectClient"); isConnected = false; @@ -74,7 +75,7 @@ export class NostrConnectClient { supportedMethods: NostrConnectMethod[] | undefined; constructor(pubkey?: string, relays: string[] = [], secretKey?: string, provider?: string) { - this.sub = new NostrMultiSubscription(); + this.sub = new MultiSubscription(`${truncateId(pubkey || "unknown")}-nostr-connect`); this.pubkey = pubkey; this.relays = relays; this.provider = provider; @@ -126,6 +127,7 @@ export class NostrConnectClient { if (!this.pubkey && response.result === "ack") { this.log("Got ack response from", event.pubkey); this.pubkey = event.pubkey; + this.sub.name = `${truncateId(event.pubkey)}-nostr-connect`; this.isConnected = true; this.listenPromise?.resolve(response.result); this.listenPromise = null; @@ -256,7 +258,9 @@ export class NostrConnectClient { } async signEvent(draft: DraftNostrEvent) { const eventString = await this.makeRequest(NostrConnectMethod.SignEvent, [JSON.stringify(draft)]); - return JSON.parse(eventString) as NostrEvent; + const event= JSON.parse(eventString) as NostrEvent; + if(!alwaysVerify(event)) throw new Error('Invalid event') + return event } nip04Encrypt(pubkey: string, plaintext: string) { return this.makeRequest(NostrConnectMethod.Nip04Encrypt, [pubkey, plaintext]); diff --git a/src/services/process-manager.ts b/src/services/process-manager.ts new file mode 100644 index 000000000..c17d7dd77 --- /dev/null +++ b/src/services/process-manager.ts @@ -0,0 +1,47 @@ +import { AbstractRelay } from "nostr-tools"; +import Process from "../classes/process"; +import relayPoolService from "./relay-pool"; + +class ProcessManager { + processes = new Set(); + + registerProcess(process: Process) { + this.processes.add(process); + } + unregisterProcess(process: Process) { + this.processes.delete(process); + for (const child of process.children) { + this.unregisterProcess(child); + } + } + + getRootProcesses() { + return Array.from(this.processes).filter((process) => !process.parent); + } + getProcessRoot(process: Process): Process { + if (process.parent) return this.getProcessRoot(process.parent); + else return process; + } + getRootProcessesForRelay(relayOrUrl: string | URL | AbstractRelay) { + const relay = relayPoolService.getRelay(relayOrUrl); + if (!relay) return new Set(); + + const rootProcesses = new Set(); + for (const process of this.processes) { + if (process.relays.has(relay)) { + rootProcesses.add(this.getProcessRoot(process)); + } + } + + return rootProcesses; + } +} + +const processManager = new ProcessManager(); + +if (import.meta.env.DEV) { + // @ts-expect-error + window.processManager = processManager; +} + +export default processManager; diff --git a/src/services/relay-pool.ts b/src/services/relay-pool.ts index 07ad1beeb..b6852fefc 100644 --- a/src/services/relay-pool.ts +++ b/src/services/relay-pool.ts @@ -5,16 +5,9 @@ const relayPoolService = new RelayPool(); setInterval(() => { if (document.visibilityState === "visible") { - relayPoolService.reconnectRelays(); - relayPoolService.pruneRelays(); + relayPoolService.disconnectFromUnused(); } -}, 1000 * 15); - -document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "visible") { - relayPoolService.reconnectRelays(); - } -}); +}, 30_000); offlineMode.subscribe((offline) => { if (offline) { diff --git a/src/services/replaceable-events.ts b/src/services/replaceable-events.ts index c69f074a7..2be5090ec 100644 --- a/src/services/replaceable-events.ts +++ b/src/services/replaceable-events.ts @@ -3,7 +3,6 @@ import _throttle from "lodash.throttle"; import SuperMap from "../classes/super-map"; import { logger } from "../helpers/debug"; -import { nameOrPubkey } from "./user-metadata"; import { getEventCoordinate } from "../helpers/nostr/event"; import createDefer, { Deferred } from "../classes/deferred"; import { localRelay } from "./local-relay"; @@ -13,6 +12,10 @@ import Subject from "../classes/subject"; import BatchKindLoader, { createCoordinate } from "../classes/batch-kind-loader"; import relayPoolService from "./relay-pool"; import { alwaysVerify } from "./verify-event"; +import { truncateId } from "../helpers/string"; +import UserSquare from "../components/icons/user-square"; +import Process from "../classes/process"; +import processManager from "./process-manager"; export type RequestOptions = { /** Always request the event from the relays */ @@ -24,17 +27,20 @@ export type RequestOptions = { }; export function getHumanReadableCoordinate(kind: number, pubkey: string, d?: string) { - return `${kind}:${nameOrPubkey(pubkey)}${d ? ":" + d : ""}`; + return `${kind}:${truncateId(pubkey)}${d ? ":" + d : ""}`; } const READ_CACHE_BATCH_TIME = 250; const WRITE_CACHE_BATCH_TIME = 250; class ReplaceableEventsService { + process: Process; + private subjects = new SuperMap>(() => new Subject()); private loaders = new SuperMap((relay) => { const loader = new BatchKindLoader(relayPoolService.requestRelay(relay), this.log.extend(relay)); loader.events.onEvent.subscribe((e) => this.handleEvent(e)); + this.process.addChild(loader.process); return loader; }); @@ -43,6 +49,13 @@ class ReplaceableEventsService { log = logger.extend("ReplaceableEventLoader"); dbLog = this.log.extend("database"); + constructor() { + this.process = new Process("ReplaceableEventsService", this); + this.process.icon = UserSquare; + this.process.active = true; + processManager.registerProcess(this.process); + } + handleEvent(event: NostrEvent, saveToCache = true) { if (!alwaysVerify(event)) return; const cord = getEventCoordinate(event); @@ -157,6 +170,10 @@ class ReplaceableEventsService { return sub; } + + destroy() { + processManager.unregisterProcess(this.process); + } } const replaceableEventsService = new ReplaceableEventsService(); diff --git a/src/services/serial-port.ts b/src/services/serial-port.ts index cd2b1f814..a3de08e47 100644 --- a/src/services/serial-port.ts +++ b/src/services/serial-port.ts @@ -1,4 +1,4 @@ -import { getEventHash, validateEvent } from "nostr-tools"; +import { getEventHash } from "nostr-tools"; import { base64 } from "@scure/base"; import { randomBytes, hexToBytes } from "@noble/hashes/utils"; import { Point } from "@noble/secp256k1"; @@ -6,6 +6,7 @@ import { Point } from "@noble/secp256k1"; import { logger } from "../helpers/debug"; import { DraftNostrEvent, NostrEvent } from "../types/nostr-event"; import createDefer, { Deferred } from "../classes/deferred"; +import { alwaysVerify } from "./verify-event"; const METHOD_PING = "/ping"; // const METHOD_LOG = '/log' @@ -225,9 +226,10 @@ async function signEvent(draft: DraftNostrEvent) { if (!signed.pubkey) signed.pubkey = await callMethodOnDevice(METHOD_PUBLIC_KEY, []); if (!signed.created_at) signed.created_at = Math.round(Date.now() / 1000); if (!signed.id) signed.id = getEventHash(signed); - if (!validateEvent(signed)) throw new Error("Tnvalid event"); signed.sig = await callMethodOnDevice(METHOD_SIGN_MESSAGE, [signed.id]); + if (!alwaysVerify(signed)) throw new Error("Invalid event"); + return signed; } diff --git a/src/services/signing.tsx b/src/services/signing.tsx index 3bd188646..0c5239249 100644 --- a/src/services/signing.tsx +++ b/src/services/signing.tsx @@ -7,6 +7,7 @@ import serialPortService from "./serial-port"; import amberSignerService from "./amber-signer"; import nostrConnectService from "./nostr-connect"; import { hexToBytes } from "@noble/hashes/utils"; +import { alwaysVerify } from "./verify-event"; const decryptedKeys = new Map>(); @@ -67,7 +68,7 @@ class SigningService { } async decryptSecKey(account: Account) { - if (account.type !== "local") throw new Error("Account dose not have a secret key"); + if (account.type !== "local") throw new Error("Account does not have a secret key"); const cache = decryptedKeys.get(account.pubkey); if (cache) return await cache; @@ -106,12 +107,13 @@ class SigningService { case "local": { const secKey = await this.decryptSecKey(account); const tmpDraft = { ...draft, pubkey: getPublicKey(hexToBytes(secKey)) }; - const event = finalizeEvent(tmpDraft, hexToBytes(secKey)) as NostrEvent; + const event = finalizeEvent(tmpDraft, hexToBytes(secKey)); return event; } case "extension": if (window.nostr) { const signed = await window.nostr.signEvent(draft); + if (!alwaysVerify(signed)) throw new Error("Invalid event"); checkSig(signed); return signed; } else throw new Error("Missing nostr extension"); @@ -149,7 +151,7 @@ class SigningService { if (window.nostr) { if (window.nostr.nip04) { return await window.nostr.nip04.decrypt(pubkey, data); - } else throw new Error("Extension dose not support decryption"); + } else throw new Error("Extension does not support decryption"); } else throw new Error("Missing nostr extension"); case "serial": if (serialPortService.supported) { @@ -179,7 +181,7 @@ class SigningService { if (window.nostr) { if (window.nostr.nip04) { return await window.nostr.nip04.encrypt(pubkey, text); - } else throw new Error("Extension dose not support encryption"); + } else throw new Error("Extension does not support encryption"); } else throw new Error("Missing nostr extension"); case "serial": if (serialPortService.supported) { diff --git a/src/services/single-event.ts b/src/services/single-event.ts index 62246caff..66e56c0eb 100644 --- a/src/services/single-event.ts +++ b/src/services/single-event.ts @@ -3,30 +3,46 @@ import _throttle from "lodash.throttle"; 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"; +import Process from "../classes/process"; +import { AbstractRelay } from "nostr-tools"; +import PersistentSubscription from "../classes/persistent-subscription"; +import processManager from "./process-manager"; +import Code02 from "../components/icons/code-02"; const RELAY_REQUEST_BATCH_TIME = 500; -class SingleEventService { +class SingleEventLoader { private subjects = new SuperMap>(() => new Subject()); - pending = new Map(); - log = logger.extend("SingleEvent"); + // pending = new SuperMap>(() => new Set()); + process: Process; + log = logger.extend("SingleEventLoader"); + + idsFromRelays = new SuperMap>(() => new Set()); + subscriptions = new Map(); + constructor() { + this.process = new Process("SingleEventLoader", this); + this.process.icon = Code02; + processManager.registerProcess(this.process); + } getSubject(id: string) { return this.subjects.get(id); } - requestEvent(id: string, relays: Iterable) { + requestEvent(id: string, urls: Iterable) { const subject = this.subjects.get(id); if (subject.value) return subject; - const safeURLs = safeRelayUrls(Array.from(relays)); - - this.pending.set(id, this.pending.get(id)?.concat(safeURLs) ?? safeURLs); - this.batchRequestsThrottle(); + const relays = relayPoolService.getRelays(urls); + for (const relay of relays) { + // this.pending.get(id).add(relay); + this.idsFromRelays.get(relay).add(id); + } + this.idsFromRelays.get(localRelay as AbstractRelay).add(id); + this.updateSubscriptionsThrottle(); return subject; } @@ -34,45 +50,39 @@ class SingleEventService { handleEvent(event: NostrEvent, cache = true) { this.subjects.get(event.id).next(event); if (cache && localRelay) localRelay.publish(event); + + for (const [relay, ids] of this.idsFromRelays) { + ids.delete(event.id); + } } - private batchRequestsThrottle = _throttle(this.batchRequests, RELAY_REQUEST_BATCH_TIME); - async batchRequests() { - if (this.pending.size === 0) return; + private updateSubscriptionsThrottle = _throttle(this.updateSubscriptions, RELAY_REQUEST_BATCH_TIME); + async updateSubscriptions() { + for (const [relay, ids] of this.idsFromRelays) { + let subscription = this.subscriptions.get(relay); + if (!subscription) { + subscription = new PersistentSubscription(relay, { + onevent: (event) => this.handleEvent(event), + oneose: () => this.updateSubscriptionsThrottle(), + }); + this.process.addChild(subscription.process); + this.subscriptions.set(relay, subscription); + } - const ids = Array.from(this.pending.keys()); - const loaded: string[] = []; - - // load from cache relay - const fromCache = localRelay ? await relayRequest(localRelay, [{ ids }]) : []; - - for (const e of fromCache) { - this.handleEvent(e, false); - loaded.push(e.id); - } - - if (loaded.length > 0) this.log(`Loaded ${loaded.length} from cache instead of relays`); - - const idsFromRelays: Record = {}; - for (const [id, relays] of this.pending) { - if (loaded.includes(id)) continue; - - for (const relay of relays) { - idsFromRelays[relay] = idsFromRelays[relay] ?? []; - idsFromRelays[relay].push(id); + if (subscription) { + if (ids.size === 0) { + subscription.close(); + } else { + // TODO: might be good to check if the ids have changed since last filter + subscription.filters = [{ ids: Array.from(ids) }]; + subscription.fire(); + } } } - - for (const [relay, ids] of Object.entries(idsFromRelays)) { - const sub = relayPoolService - .requestRelay(relay) - .subscribe([{ ids }], { onevent: (event) => this.handleEvent(event), oneose: () => sub.close() }); - } - this.pending.clear(); } } -const singleEventService = new SingleEventService(); +const singleEventService = new SingleEventLoader(); if (import.meta.env.DEV) { //@ts-expect-error diff --git a/src/services/timeline-cache.ts b/src/services/timeline-cache.ts index d430c7e75..d0c9496bc 100644 --- a/src/services/timeline-cache.ts +++ b/src/services/timeline-cache.ts @@ -27,7 +27,7 @@ class TimelineCacheService { if (deadTimeline) { this.log(`Destroying ${deadTimeline.name}`); this.timelines.delete(deleteKey); - deadTimeline.cleanup(); + deadTimeline.destroy(); } } diff --git a/src/services/user-mailboxes.ts b/src/services/user-mailboxes.ts index f346caf66..a4340434b 100644 --- a/src/services/user-mailboxes.ts +++ b/src/services/user-mailboxes.ts @@ -43,7 +43,7 @@ class UserMailboxesService { // also fetch the relays from the users contacts const contactsSub = replaceableEventsService.requestEvent(relays, kinds.Contacts, pubkey, undefined, opts); sub.connectWithMapper(contactsSub, (event, next, value) => { - // NOTE: only use relays from contact list if the user dose not have a NIP-65 relay list + // NOTE: only use relays from contact list if the user does not have a NIP-65 relay list const relays = relaysFromContactsEvent(event); if (relays.length > 0 && !value) { next({ diff --git a/src/services/verify-event/index.ts b/src/services/verify-event/index.ts index 3da9cbfbb..33ad6a791 100644 --- a/src/services/verify-event/index.ts +++ b/src/services/verify-event/index.ts @@ -55,10 +55,10 @@ try { break; } } catch (error) { - console.error("Failed to initialize event verification method, falling back to internal nostr-tools"); + console.error("Failed to initialize event verification method, disabling"); console.log(error); - localStorage.setItem(localStorageKey, "internal"); + localStorage.setItem(localStorageKey, "none"); verifyEventMethod = alwaysVerify = verifyEvent; } diff --git a/src/services/wasm-relay/index.ts b/src/services/wasm-relay/index.ts index 7b7bcacd2..891e1bc90 100644 --- a/src/services/wasm-relay/index.ts +++ b/src/services/wasm-relay/index.ts @@ -42,7 +42,7 @@ export default class WasmRelay implements SimpleRelay { async count(filters: Filter[], params: { id?: string | null }) { if (!this.worker) throw new Error("Worker not setup"); - return await this.worker.count(["REQ", params.id || nanoid(), ...filters]); + return await this.worker.count(["REQ", params.id || nanoid(8), ...filters]); } private async executeSubscription(sub: Subscription) { @@ -69,7 +69,7 @@ export default class WasmRelay implements SimpleRelay { this.subscriptions.delete(options.id); } - const id = options.id || nanoid(); + const id = options.id || nanoid(8); const sub = { id, diff --git a/src/views/channels/channel.tsx b/src/views/channels/channel.tsx index 1c87fc774..6c14466d7 100644 --- a/src/views/channels/channel.tsx +++ b/src/views/channels/channel.tsx @@ -24,6 +24,7 @@ import TimelineActionAndStatus from "../../components/timeline-page/timeline-act import ChannelMessageForm from "./components/send-message-form"; import useParamsEventPointer from "../../hooks/use-params-event-pointer"; import { useReadRelays } from "../../hooks/use-client-relays"; +import { truncateId } from "../../helpers/string"; const ChannelChatLog = memo(({ timeline, channel }: { timeline: TimelineLoader; channel: NostrEvent }) => { const messages = useSubject(timeline.timeline); @@ -57,7 +58,7 @@ function ChannelPage({ channel }: { channel: NostrEvent }) { [clientMuteFilter], ); const timeline = useTimelineLoader( - `${channel.id}-chat-messages`, + `${truncateId(channel.id)}-chat-messages`, relays, { kinds: [kinds.ChannelMessage], diff --git a/src/views/dms/chat.tsx b/src/views/dms/chat.tsx index 4e1c25beb..456ec3524 100644 --- a/src/views/dms/chat.tsx +++ b/src/views/dms/chat.tsx @@ -26,6 +26,7 @@ import useParamsProfilePointer from "../../hooks/use-params-pubkey-pointer"; import useUserMailboxes from "../../hooks/use-user-mailboxes"; import RelaySet from "../../classes/relay-set"; import useAppSettings from "../../hooks/use-app-settings"; +import { truncateId } from "../../helpers/string"; /** This is broken out from DirectMessageChatPage for performance reasons. Don't use outside of file */ const ChatLog = memo(({ timeline }: { timeline: TimelineLoader }) => { @@ -76,7 +77,7 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) { const otherMailboxes = useUserMailboxes(pubkey); const mailboxes = useUserMailboxes(account.pubkey); const timeline = useTimelineLoader( - `${pubkey}-${account.pubkey}-messages`, + `${truncateId(pubkey)}-${truncateId(account.pubkey)}-messages`, RelaySet.from(mailboxes?.inbox, mailboxes?.outbox, otherMailboxes?.inbox, otherMailboxes?.outbox), [ { diff --git a/src/views/dms/index.tsx b/src/views/dms/index.tsx index 39075b3a8..1c707a632 100644 --- a/src/views/dms/index.tsx +++ b/src/views/dms/index.tsx @@ -16,7 +16,7 @@ import IntersectionObserverProvider, { } from "../../providers/local/intersection-observer"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status"; -import { useDMTimeline } from "../../providers/global/dm-timeline"; +import { useDMTimeline } from "../../providers/global/dms-provider"; import UserName from "../../components/user/user-name"; import { useDecryptionContainer } from "../../providers/global/dycryption-provider"; import { NostrEvent } from "../../types/nostr-event"; diff --git a/src/views/dvm-feed/feed.tsx b/src/views/dvm-feed/feed.tsx index a545e8676..465615e73 100644 --- a/src/views/dvm-feed/feed.tsx +++ b/src/views/dvm-feed/feed.tsx @@ -38,6 +38,7 @@ import useParamsAddressPointer from "../../hooks/use-params-address-pointer"; import DVMParams from "./components/dvm-params"; import useUserMailboxes from "../../hooks/use-user-mailboxes"; import { usePublishEvent } from "../../providers/global/publish-provider"; +import { getHumanReadableCoordinate } from "../../services/replaceable-events"; function DVMFeedPage({ pointer }: { pointer: AddressPointer }) { const [since] = useState(() => dayjs().subtract(1, "hour").unix()); @@ -48,14 +49,18 @@ function DVMFeedPage({ pointer }: { pointer: AddressPointer }) { const dvmRelays = useUserMailboxes(pointer.pubkey)?.relays; const readRelays = useReadRelays(dvmRelays); - const timeline = useTimelineLoader(`${pointer.kind}:${pointer.pubkey}:${pointer.identifier}-jobs`, readRelays, [ - { - authors: [account.pubkey, pointer.pubkey], - "#p": [account.pubkey, pointer.pubkey], - kinds: [DVM_CONTENT_DISCOVERY_JOB_KIND, DVM_CONTENT_DISCOVERY_RESULT_KIND, DVM_STATUS_KIND], - since, - }, - ]); + const timeline = useTimelineLoader( + `${getHumanReadableCoordinate(pointer.kind, pointer.pubkey, pointer.identifier)}-jobs`, + readRelays, + [ + { + authors: [account.pubkey, pointer.pubkey], + "#p": [account.pubkey, pointer.pubkey], + kinds: [DVM_CONTENT_DISCOVERY_JOB_KIND, DVM_CONTENT_DISCOVERY_RESULT_KIND, DVM_STATUS_KIND], + since, + }, + ], + ); const events = useSubject(timeline.timeline); const jobs = groupEventsIntoJobs(events); diff --git a/src/views/launchpad/components/dms-card.tsx b/src/views/launchpad/components/dms-card.tsx index ecfd331b6..a3c20589c 100644 --- a/src/views/launchpad/components/dms-card.tsx +++ b/src/views/launchpad/components/dms-card.tsx @@ -4,7 +4,7 @@ import { Link as RouterLink, useNavigate } from "react-router-dom"; import KeyboardShortcut from "../../../components/keyboard-shortcut"; import useCurrentAccount from "../../../hooks/use-current-account"; -import { useDMTimeline } from "../../../providers/global/dm-timeline"; +import { useDMTimeline } from "../../../providers/global/dms-provider"; import useSubject from "../../../hooks/use-subject"; import { KnownConversation, diff --git a/src/views/launchpad/components/notifications-card.tsx b/src/views/launchpad/components/notifications-card.tsx index a62b90103..54e121bad 100644 --- a/src/views/launchpad/components/notifications-card.tsx +++ b/src/views/launchpad/components/notifications-card.tsx @@ -2,7 +2,7 @@ import { Button, Card, CardBody, CardHeader, CardProps, Heading, Link } from "@c import { Link as RouterLink, useNavigate } from "react-router-dom"; import KeyboardShortcut from "../../../components/keyboard-shortcut"; -import { useNotifications } from "../../../providers/global/notifications"; +import { useNotifications } from "../../../providers/global/notifications-provider"; import useSubject from "../../../hooks/use-subject"; import { NotificationType, typeSymbol } from "../../../classes/notifications"; import NotificationItem from "../../notifications/components/notification-item"; diff --git a/src/views/notifications/index.tsx b/src/views/notifications/index.tsx index 9997d3612..6034daeb7 100644 --- a/src/views/notifications/index.tsx +++ b/src/views/notifications/index.tsx @@ -12,7 +12,7 @@ import IntersectionObserverProvider, { } from "../../providers/local/intersection-observer"; import useSubject from "../../hooks/use-subject"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; -import { useNotifications } from "../../providers/global/notifications"; +import { useNotifications } from "../../providers/global/notifications-provider"; import { getEventUID, isReply } from "../../helpers/nostr/event"; import PeopleListProvider, { usePeopleListContext } from "../../providers/local/people-list-provider"; import PeopleListSelection from "../../components/people-list-selection/people-list-selection"; diff --git a/src/views/notifications/threads.tsx b/src/views/notifications/threads.tsx index c3f3d8776..323cd964e 100644 --- a/src/views/notifications/threads.tsx +++ b/src/views/notifications/threads.tsx @@ -8,7 +8,7 @@ import RequireCurrentAccount from "../../providers/route/require-current-account import VerticalPageLayout from "../../components/vertical-page-layout"; import useSubject from "../../hooks/use-subject"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; -import { useNotifications } from "../../providers/global/notifications"; +import { useNotifications } from "../../providers/global/notifications-provider"; import { TORRENT_COMMENT_KIND } from "../../helpers/nostr/torrents"; import { groupByRoot } from "../../helpers/notification"; import { NostrEvent } from "../../types/nostr-event"; diff --git a/src/views/profile/edit.tsx b/src/views/profile/edit.tsx index a4413ea33..d17565954 100644 --- a/src/views/profile/edit.tsx +++ b/src/views/profile/edit.tsx @@ -121,7 +121,7 @@ const MetadataForm = ({ defaultValues, onSubmit }: MetadataFormProps) => { try { const id = await dnsIdentityService.fetchIdentity(address); if (!id) return "Cant find NIP-05 ID"; - if (id.pubkey !== account.pubkey) return "Pubkey dose not match"; + if (id.pubkey !== account.pubkey) return "Pubkey does not match"; } catch (e) { return "Failed to fetch ID"; } diff --git a/src/views/relays/cache/database/index.tsx b/src/views/relays/cache/database/index.tsx index a87e9bc89..09b6c204d 100644 --- a/src/views/relays/cache/database/index.tsx +++ b/src/views/relays/cache/database/index.tsx @@ -15,7 +15,7 @@ const InternalDatabasePage = lazy(() => import("./internal")); export default function DatabaseView() { let content = ( - noStrudel dose not have access to the selected cache relays database{" "} + noStrudel does not have access to the selected cache relays database{" "} Change cache relay diff --git a/src/views/signin/address/create.tsx b/src/views/signin/address/create.tsx index e1f891caf..cd8d631be 100644 --- a/src/views/signin/address/create.tsx +++ b/src/views/signin/address/create.tsx @@ -90,8 +90,8 @@ export default function LoginNostrAddressCreate() { if (!metadata.nip05) throw new Error("Provider missing nip05 address"); const nip05 = await dnsIdentityService.fetchIdentity(metadata.nip05); if (!nip05 || nip05.pubkey !== selected.pubkey) throw new Error("Invalid provider"); - if (nip05.name !== "_") throw new Error("Provider dose not own the domain"); - if (!nip05.hasNip46) throw new Error("Provider dose not support NIP-46"); + if (nip05.name !== "_") throw new Error("Provider does not own the domain"); + if (!nip05.hasNip46) throw new Error("Provider does not support NIP-46"); const relays = safeRelayUrls(nip05.nip46Relays || nip05.relays); if (relays.length === 0) throw new Error("Cant find providers relays"); diff --git a/src/views/signin/nip05.tsx b/src/views/signin/nip05.tsx index 39b54ab81..4a8215f8f 100644 --- a/src/views/signin/nip05.tsx +++ b/src/views/signin/nip05.tsx @@ -62,7 +62,7 @@ export default function LoginNip05View() { return toast({ status: "error", title: "No relay selected" }); } - // add the account if it dose not exist + // add the account if it does not exist if (!accountService.hasAccount(pubkey)) { const bootstrapRelays = new Set(); diff --git a/src/views/task-manager/layout.tsx b/src/views/task-manager/layout.tsx index 230a126c9..96c8e22e0 100644 --- a/src/views/task-manager/layout.tsx +++ b/src/views/task-manager/layout.tsx @@ -1,13 +1,16 @@ import { Tab, TabIndicator, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; -const tabs = ["network", "publish-log", "database"]; +const tabs = ["publish-log", "relays", "processes", "database"]; export default function TaskManagerLayout() { const location = useLocation(); const navigate = useNavigate(); - const index = tabs.indexOf(location.pathname.split("/")[1] || "network"); + const base = location.pathname.split("/")[1] || "network"; + let index = tabs.indexOf(base); + + if (base === "r") index = 1; return ( navigate("/" + tabs[i], { replace: true })} > - Network Publish Log + Relays + Processes Database @@ -35,6 +39,9 @@ export default function TaskManagerLayout() { + + + diff --git a/src/views/task-manager/modal.tsx b/src/views/task-manager/modal.tsx index a73ab3075..347d7b17a 100644 --- a/src/views/task-manager/modal.tsx +++ b/src/views/task-manager/modal.tsx @@ -19,7 +19,7 @@ import { RouterProvider, createMemoryRouter } from "react-router-dom"; import { PersistentSubject } from "../../classes/subject"; import useSubject from "../../hooks/use-subject"; import DatabaseView from "../relays/cache/database"; -import TaskManagerNetwork from "./network"; +import TaskManagerRelays from "./relays"; import { Suspense } from "react"; type Router = ReturnType; diff --git a/src/views/task-manager/network/index.tsx b/src/views/task-manager/network/index.tsx deleted file mode 100644 index 97d54220b..000000000 --- a/src/views/task-manager/network/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { - Accordion, - AccordionButton, - AccordionIcon, - AccordionItem, - AccordionPanel, - Box, - Spacer, - Text, -} from "@chakra-ui/react"; -import relayPoolService from "../../../services/relay-pool"; -import { RelayFavicon } from "../../../components/relay-favicon"; -import { RelayStatus } from "../../../components/relay-status"; - -export default function TaskManagerNetwork() { - return ( - - {Array.from(relayPoolService.relays.values()).map((relay) => ( - -

- - - {relay.url} - - - - -

- - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et - dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex - ea commodo consequat. - -
- ))} -
- ); -} diff --git a/src/views/task-manager/network/inspect-relay.tsx b/src/views/task-manager/network/inspect-relay.tsx deleted file mode 100644 index 2b52d65d9..000000000 --- a/src/views/task-manager/network/inspect-relay.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import VerticalPageLayout from "../../../components/vertical-page-layout"; - -export default function InspectRelayView() { - return ; -} diff --git a/src/views/task-manager/processes/index.tsx b/src/views/task-manager/processes/index.tsx new file mode 100644 index 000000000..0a210c4cf --- /dev/null +++ b/src/views/task-manager/processes/index.tsx @@ -0,0 +1,19 @@ +import { Flex, useForceUpdate, useInterval } from "@chakra-ui/react"; + +import ProcessBranch from "./process/process-tree"; +import processManager from "../../../services/process-manager"; + +export default function TaskManagerProcesses() { + const update = useForceUpdate(); + useInterval(update, 500); + + const rootProcesses = processManager.getRootProcesses(); + + return ( + + {rootProcesses.map((process) => ( + + ))} + + ); +} diff --git a/src/views/task-manager/processes/process/process-icon.tsx b/src/views/task-manager/processes/process/process-icon.tsx new file mode 100644 index 000000000..9b262623f --- /dev/null +++ b/src/views/task-manager/processes/process/process-icon.tsx @@ -0,0 +1,10 @@ +import { QuestionIcon } from "@chakra-ui/icons"; +import { ComponentWithAs, IconProps } from "@chakra-ui/react"; + +import Process from "../../../../classes/process"; + +export default function ProcessIcon({ process, ...props }: { process: Process } & IconProps) { + let IconComponent: ComponentWithAs<"svg", IconProps> = process.icon || QuestionIcon; + + return ; +} diff --git a/src/views/task-manager/processes/process/process-tree.tsx b/src/views/task-manager/processes/process/process-tree.tsx new file mode 100644 index 000000000..d34d0936f --- /dev/null +++ b/src/views/task-manager/processes/process/process-tree.tsx @@ -0,0 +1,46 @@ +import { Flex, Text, useDisclosure } from "@chakra-ui/react"; + +import ProcessIcon from "./process-icon"; +import Process from "../../../../classes/process"; +import ExpandButton from "../../../tools/event-console/expand-button"; + +export default function ProcessBranch({ + process, + level = 0, + filter, +}: { + process: Process; + level?: number; + filter?: (process: Process) => boolean; +}) { + const showChildren = useDisclosure({ defaultIsOpen: !!process.parent }); + + return ( + <> + + + + {process.type} + + + {process.id} + + {process.children.size > 0 && ( + + )} + + {process.name} + {process.relays.size > 1 + ? ` ${process.relays.size} relays` + : Array.from(process.relays) + .map((r) => r.url) + .join(", ")} + + + {showChildren.isOpen && + Array.from(process.children) + .filter((p) => (filter ? filter(p) : true)) + .map((child) => )} + + ); +} diff --git a/src/views/task-manager/provider.tsx b/src/views/task-manager/provider.tsx index bc1360244..1d941556e 100644 --- a/src/views/task-manager/provider.tsx +++ b/src/views/task-manager/provider.tsx @@ -2,14 +2,14 @@ import { PropsWithChildren, createContext, useCallback, useContext, useEffect, u import { Router, Location, To, createMemoryRouter, RouteObject } from "react-router-dom"; import { useRouterMarker } from "../../providers/drawer-sub-view-provider"; import { logger } from "../../helpers/debug"; -import { RouteProviders } from "../../providers/route"; -import InspectRelayView from "./network/inspect-relay"; +import InspectRelayView from "./relays/inspect-relay"; import TaskManagerModal from "./modal"; import TaskManagerLayout from "./layout"; -import TaskManagerNetwork from "./network"; +import TaskManagerRelays from "./relays"; import TaskManagerDatabase from "./database"; import PublishLogView from "./publish-log"; +import TaskManagerProcesses from "./processes"; type Router = ReturnType; @@ -30,18 +30,16 @@ const routes: RouteObject[] = [ element: , children: [ { - path: "network", - element: , - children: [ - { - path: ":url", - element: ( - - - - ), - }, - ], + path: "relays", + element: , + }, + { + path: "r/:url", + element: , + }, + { + path: "processes", + element: , }, { path: "publish-log", element: }, { diff --git a/src/views/task-manager/relays/index.tsx b/src/views/task-manager/relays/index.tsx new file mode 100644 index 000000000..8bafb8b93 --- /dev/null +++ b/src/views/task-manager/relays/index.tsx @@ -0,0 +1,24 @@ +import { Flex, LinkBox, Spacer } from "@chakra-ui/react"; +import { Link as RouterLink } from "react-router-dom"; + +import relayPoolService from "../../../services/relay-pool"; +import { RelayFavicon } from "../../../components/relay-favicon"; +import { RelayStatus } from "../../../components/relay-status"; +import HoverLinkOverlay from "../../../components/hover-link-overlay"; + +export default function TaskManagerRelays() { + return ( + + {Array.from(relayPoolService.relays.values()).map((relay) => ( + + + + {relay.url} + + + + + ))} + + ); +} diff --git a/src/views/task-manager/relays/inspect-relay.tsx b/src/views/task-manager/relays/inspect-relay.tsx new file mode 100644 index 000000000..d22a3bd0e --- /dev/null +++ b/src/views/task-manager/relays/inspect-relay.tsx @@ -0,0 +1,64 @@ +import { useCallback, useMemo } from "react"; +import { Button, ButtonGroup, Flex, Heading, Spacer, useForceUpdate, useInterval, useToast } from "@chakra-ui/react"; +import { useParams } from "react-router-dom"; + +import VerticalPageLayout from "../../../components/vertical-page-layout"; +import BackButton from "../../../components/router/back-button"; +import relayPoolService from "../../../services/relay-pool"; +import useSubject from "../../../hooks/use-subject"; + +import ProcessBranch from "../processes/process/process-tree"; +import processManager from "../../../services/process-manager"; +import RelayAuthButton from "../../../components/relays/relay-auth-button"; + +export default function InspectRelayView() { + const toast = useToast(); + const { url } = useParams(); + if (!url) throw new Error("Missing url param"); + + const update = useForceUpdate(); + useInterval(update, 500); + + const relay = useMemo(() => relayPoolService.requestRelay(url, false), [url]); + const connecting = useSubject(relayPoolService.connecting.get(relay)); + + const connect = useCallback(async () => { + try { + await relayPoolService.requestConnect(relay, false); + } catch (error) { + if (error instanceof Error) toast({ status: "error", description: error.message }); + } + }, [toast]); + + const rootProcesses = processManager.getRootProcessesForRelay(relay); + + return ( + + + + {url} + + + + + + + + + {Array.from(rootProcesses).map((process) => ( + (p.relays.size > 0 ? p.relays.has(relay) : p.children.size > 0)} + /> + ))} + + + ); +} diff --git a/src/views/user/relays.tsx b/src/views/user/relays.tsx index e20375b60..3dfa9f391 100644 --- a/src/views/user/relays.tsx +++ b/src/views/user/relays.tsx @@ -14,6 +14,7 @@ import { useRelayInfo } from "../../hooks/use-relay-info"; import { ErrorBoundary } from "../../components/error-boundary"; import { RelayShareButton } from "../relays/components/relay-share-button"; import useUserMailboxes from "../../hooks/use-user-mailboxes"; +import { truncateId } from "../../helpers/string"; function Relay({ url, reviews }: { url: string; reviews: NostrEvent[] }) { const { info } = useRelayInfo(url); @@ -59,7 +60,7 @@ const UserRelaysTab = () => { const mailboxes = useUserMailboxes(pubkey); const readRelays = useReadRelays(mailboxes?.outbox); - const timeline = useTimelineLoader(`${pubkey}-relay-reviews`, readRelays, { + const timeline = useTimelineLoader(`${truncateId(pubkey)}-relay-reviews`, readRelays, { authors: [pubkey], kinds: [1985], "#l": ["review/relay"], diff --git a/yarn.lock b/yarn.lock index eddb60959..9d8b0c47e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6213,10 +6213,10 @@ normalize-package-data@^2.5.0: semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" -nostr-idb@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/nostr-idb/-/nostr-idb-2.1.1.tgz#5aaf4ebb793266c87ab7c9d72ed4be3bcae79fc2" - integrity sha512-vUdRPOJkWtEovsL0CqnniiuwIqastHby7nf2NKyhvO5MV5Dyl9CBxs9nIcAHB5GJHDV7nt+t6SF193Odorp/1g== +nostr-idb@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/nostr-idb/-/nostr-idb-2.1.4.tgz#5a7b8886b2e18dde9bd4a95002be3033abe5864a" + integrity sha512-tD8JmWvpxoqpl+9wOQgFk5BG+fQY0Cz0PdxMTZXP8xvRO9YDvCMn111zwBRqwth8CnGIxayjltFvwtXHCtlXtA== dependencies: debug "^4.3.4" idb "^8.0.0" @@ -6262,10 +6262,10 @@ nostr-tools@^2.3.2: optionalDependencies: nostr-wasm v0.1.0 -nostr-tools@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.5.0.tgz#083c8a22eb88c65f30d88a25e200ea2274348663" - integrity sha512-G02O3JYNCfhx9NDjd3NOCw/5ck8PX5hiOIhHKpsXyu49ZtZbxGH3OLP9tf0fpUZ+EVWdjIYFR689sV0i7+TOng== +nostr-tools@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.5.1.tgz#614d6aaf5c21df6b239d7ed42fdf77616a4621e7" + integrity sha512-bpkhGGAhdiCN0irfV+xoH3YP5CQeOXyXzUq7SYeM6D56xwTXZCPEmBlUGqFVfQidvRsoVeVxeAiOXW2c2HxoRQ== dependencies: "@noble/ciphers" "^0.5.1" "@noble/curves" "1.2.0"