mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-26 17:52:18 +01:00
Show timelines, subscriptions, and services in task manager
This commit is contained in:
parent
d20f6989f3
commit
7506141f54
5
.changeset/gentle-deers-fold.md
Normal file
5
.changeset/gentle-deers-fold.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Show timelines, subscriptions, and services in task manager
|
@ -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",
|
||||
|
@ -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<string>();
|
||||
private requested = new Map<string, Date>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -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<number>();
|
||||
|
||||
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<number>((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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
},
|
||||
|
169
src/classes/multi-subscription.ts
Normal file
169
src/classes/multi-subscription.ts
Normal file
@ -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<AbstractRelay>();
|
||||
subscriptions = new Map<AbstractRelay, PersistentSubscription>();
|
||||
cacheSubscription: PersistentSubscription | null = null;
|
||||
|
||||
state = MultiSubscription.CLOSED;
|
||||
onEvent = new ControlledObservable<NostrEvent>();
|
||||
seenEvents = new Set<string>();
|
||||
|
||||
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<string | URL | AbstractRelay>) {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
@ -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<AbstractRelay, Subscription>();
|
||||
|
||||
state = NostrMultiSubscription.INIT;
|
||||
onEvent = new ControlledObservable<NostrEvent>();
|
||||
seenEvents = new Set<string>();
|
||||
|
||||
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<string>) {
|
||||
// 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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -22,7 +22,7 @@ export default class NostrSubscription {
|
||||
onEOSE = new ControlledObservable<number>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
85
src/classes/persistent-subscription.ts
Normal file
85
src/classes/persistent-subscription.ts
Normal file
@ -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<SubscriptionParams>;
|
||||
|
||||
subscription: Subscription | null = null;
|
||||
|
||||
constructor(relay: AbstractRelay, params?: Partial<SubscriptionParams>) {
|
||||
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);
|
||||
}
|
||||
}
|
53
src/classes/process.ts
Normal file
53
src/classes/process.ts
Normal file
@ -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<AbstractRelay | SimpleRelay>();
|
||||
|
||||
// the parent process
|
||||
parent?: Process;
|
||||
// any children this process has created
|
||||
children = new Set<Process>();
|
||||
|
||||
constructor(type: string, source: any, relays?: Iterable<AbstractRelay | SimpleRelay>) {
|
||||
this.type = type;
|
||||
this.source = source;
|
||||
|
||||
this.relays = new Set(relays);
|
||||
}
|
||||
|
||||
static forkOrCreate(name: string, source: any, relays: Iterable<AbstractRelay | SimpleRelay>, 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<AbstractRelay | SimpleRelay>) {
|
||||
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;
|
||||
}
|
||||
}
|
@ -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<string, AbstractRelay>();
|
||||
onRelayCreated = new Subject<AbstractRelay>();
|
||||
onRelayChallenge = new Subject<[AbstractRelay, string]>();
|
||||
|
||||
relayClaims = new Map<string, Set<any>>();
|
||||
connectionErrors = new SuperMap<AbstractRelay, Error[]>(() => []);
|
||||
connecting = new SuperMap<AbstractRelay, PersistentSubject<boolean>>(() => 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<string | URL | AbstractRelay>) {
|
||||
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<any>;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -6,9 +6,6 @@ export default class SuperMap<Key, Value> extends Map<Key, Value> {
|
||||
this.newValue = newValue;
|
||||
}
|
||||
|
||||
has(key: Key) {
|
||||
return true;
|
||||
}
|
||||
get(key: Key) {
|
||||
let value = super.get(key);
|
||||
if (value === undefined) {
|
||||
|
@ -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<NostrEvent[]>([]);
|
||||
@ -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<string, ChunkedRequest>();
|
||||
|
||||
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<ChunkedRequest, ZenObservable.Subscription[]>(() => []);
|
||||
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<string>) {
|
||||
this.relays = Array.from(relays);
|
||||
setRelays(relays: Iterable<string | URL | AbstractRelay>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ export default function EventKindsTable({
|
||||
);
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<TableContainer minH="sm">
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
|
@ -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 <InlineCachuCard token={match[0]} zIndex={1} position="relative" />;
|
||||
},
|
||||
name: "emoji",
|
||||
|
@ -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;
|
||||
|
@ -10,7 +10,12 @@ export default function TaskManagerButton({ ...props }: Omit<ButtonProps, "child
|
||||
const { openTaskManager } = useTaskManagerContext();
|
||||
|
||||
return (
|
||||
<Button variant="link" justifyContent="space-between" onClick={() => openTaskManager("/network")} {...props}>
|
||||
<Button
|
||||
variant="link"
|
||||
justifyContent="space-between"
|
||||
onClick={() => openTaskManager(log.length === 0 ? "/relays" : "/publish-log")}
|
||||
{...props}
|
||||
>
|
||||
Task Manager
|
||||
{log.length > 0 && <PublishActionStatusTag action={log[log.length - 1]} />}
|
||||
</Button>
|
||||
|
@ -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 <Badge colorScheme={getStatusColor(relay)}>{getStatusText(relay)}</Badge>;
|
||||
return <Badge colorScheme={getStatusColor(relay, connecting)}>{getStatusText(relay, connecting)}</Badge>;
|
||||
};
|
||||
|
43
src/components/relays/relay-auth-button.tsx
Normal file
43
src/components/relays/relay-auth-button.tsx
Normal file
@ -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 (
|
||||
<Button onClick={auth} isLoading={loading}>
|
||||
Authenticate
|
||||
</Button>
|
||||
);
|
||||
return null;
|
||||
}
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -97,6 +97,12 @@ export function splitQueryByPubkeys(query: NostrQuery, relayPubkeyMap: Record<st
|
||||
return filtersByRelay;
|
||||
}
|
||||
|
||||
// NOTE: this is a hack because nostr-tools does not expose the "challenge" field on relays
|
||||
export function getChallenge(relay: AbstractRelay): string {
|
||||
// @ts-expect-error
|
||||
return relay.challenge;
|
||||
}
|
||||
|
||||
export function relayRequest(relay: SimpleRelay, filters: Filter[], opts: SubscriptionOptions = {}) {
|
||||
return new Promise<NostrEvent[]>((res) => {
|
||||
const events: NostrEvent[] = [];
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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) : []);
|
||||
|
@ -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<string>) {
|
||||
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
|
||||
? {
|
||||
|
@ -10,11 +10,11 @@ export default function useUserMetadata(
|
||||
additionalRelays: Iterable<string> = [],
|
||||
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);
|
||||
|
||||
|
@ -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<string>) {
|
||||
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
|
||||
? {
|
||||
|
@ -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
|
||||
? [
|
@ -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;
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
? {
|
@ -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<NostrEvent>;
|
||||
requestSignature: (draft: EventTemplate | DraftNostrEvent) => Promise<VerifiedEvent>;
|
||||
requestDecrypt: (data: string, pubkey: string) => Promise<string>;
|
||||
requestEncrypt: (data: string, pubkey: string) => Promise<string>;
|
||||
};
|
||||
|
@ -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<NostrEvent> {
|
||||
async function signEvent(draft: DraftNostrEvent & { pubkey: string }): Promise<VerifiedEvent> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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]);
|
||||
|
47
src/services/process-manager.ts
Normal file
47
src/services/process-manager.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { AbstractRelay } from "nostr-tools";
|
||||
import Process from "../classes/process";
|
||||
import relayPoolService from "./relay-pool";
|
||||
|
||||
class ProcessManager {
|
||||
processes = new Set<Process>();
|
||||
|
||||
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<Process>();
|
||||
|
||||
const rootProcesses = new Set<Process>();
|
||||
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;
|
@ -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) {
|
||||
|
@ -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<string, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
|
||||
private loaders = new SuperMap<string, BatchKindLoader>((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();
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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<string, string | Promise<string>>();
|
||||
|
||||
@ -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) {
|
||||
|
@ -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<string, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
|
||||
pending = new Map<string, string[]>();
|
||||
log = logger.extend("SingleEvent");
|
||||
// pending = new SuperMap<string, Set<AbstractRelay>>(() => new Set());
|
||||
process: Process;
|
||||
log = logger.extend("SingleEventLoader");
|
||||
|
||||
idsFromRelays = new SuperMap<AbstractRelay, Set<string>>(() => new Set());
|
||||
subscriptions = new Map<AbstractRelay, PersistentSubscription>();
|
||||
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<string>) {
|
||||
requestEvent(id: string, urls: Iterable<string | URL | AbstractRelay>) {
|
||||
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<string, string[]> = {};
|
||||
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
|
||||
|
@ -27,7 +27,7 @@ class TimelineCacheService {
|
||||
if (deadTimeline) {
|
||||
this.log(`Destroying ${deadTimeline.name}`);
|
||||
this.timelines.delete(deleteKey);
|
||||
deadTimeline.cleanup();
|
||||
deadTimeline.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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({
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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],
|
||||
|
@ -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),
|
||||
[
|
||||
{
|
||||
|
@ -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";
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
}
|
||||
|
2
src/views/relays/cache/database/index.tsx
vendored
2
src/views/relays/cache/database/index.tsx
vendored
@ -15,7 +15,7 @@ const InternalDatabasePage = lazy(() => import("./internal"));
|
||||
export default function DatabaseView() {
|
||||
let content = (
|
||||
<Text>
|
||||
noStrudel dose not have access to the selected cache relays database{" "}
|
||||
noStrudel does not have access to the selected cache relays database{" "}
|
||||
<Link as={RouterLink} to="/relays/cache" color="blue.500">
|
||||
Change cache relay
|
||||
</Link>
|
||||
|
@ -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");
|
||||
|
||||
|
@ -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<string>();
|
||||
|
||||
|
@ -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 (
|
||||
<Tabs
|
||||
@ -22,8 +25,9 @@ export default function TaskManagerLayout() {
|
||||
onChange={(i) => navigate("/" + tabs[i], { replace: true })}
|
||||
>
|
||||
<TabList overflowX="auto" overflowY="hidden" flexShrink={0} mr="10">
|
||||
<Tab>Network</Tab>
|
||||
<Tab>Publish Log</Tab>
|
||||
<Tab>Relays</Tab>
|
||||
<Tab>Processes</Tab>
|
||||
<Tab>Database</Tab>
|
||||
</TabList>
|
||||
<TabIndicator height="2px" bg="primary.500" borderRadius="1px" />
|
||||
@ -35,6 +39,9 @@ export default function TaskManagerLayout() {
|
||||
<TabPanel p={0} minH="50vh">
|
||||
<Outlet />
|
||||
</TabPanel>
|
||||
<TabPanel p={0} minH="50vh">
|
||||
<Outlet />
|
||||
</TabPanel>
|
||||
<TabPanel minH="50vh">
|
||||
<Outlet />
|
||||
</TabPanel>
|
||||
|
@ -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<typeof createMemoryRouter>;
|
||||
|
@ -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 (
|
||||
<Accordion>
|
||||
{Array.from(relayPoolService.relays.values()).map((relay) => (
|
||||
<AccordionItem key={relay.url}>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<RelayFavicon relay={relay.url} size="sm" mr="2" />
|
||||
<Text isTruncated>{relay.url}</Text>
|
||||
<Spacer />
|
||||
<RelayStatus url={relay.url} />
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
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.
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
);
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import VerticalPageLayout from "../../../components/vertical-page-layout";
|
||||
|
||||
export default function InspectRelayView() {
|
||||
return <VerticalPageLayout></VerticalPageLayout>;
|
||||
}
|
19
src/views/task-manager/processes/index.tsx
Normal file
19
src/views/task-manager/processes/index.tsx
Normal file
@ -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 (
|
||||
<Flex direction="column">
|
||||
{rootProcesses.map((process) => (
|
||||
<ProcessBranch key={process.id} process={process} />
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
10
src/views/task-manager/processes/process/process-icon.tsx
Normal file
10
src/views/task-manager/processes/process/process-icon.tsx
Normal file
@ -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 <IconComponent color={process.active ? "green.500" : "gray.500"} {...props} />;
|
||||
}
|
46
src/views/task-manager/processes/process/process-tree.tsx
Normal file
46
src/views/task-manager/processes/process/process-tree.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<Flex gap="2" p="2" alignItems="center" ml={level + "em"}>
|
||||
<ProcessIcon process={process} boxSize={6} />
|
||||
<Text as="span" isTruncated fontWeight="bold">
|
||||
{process.type}
|
||||
</Text>
|
||||
<Text as="span" color="GrayText">
|
||||
{process.id}
|
||||
</Text>
|
||||
{process.children.size > 0 && (
|
||||
<ExpandButton isOpen={showChildren.isOpen} onToggle={showChildren.onToggle} variant="ghost" size="xs" />
|
||||
)}
|
||||
<Text fontSize="sm" color="GrayText">
|
||||
{process.name}
|
||||
{process.relays.size > 1
|
||||
? ` ${process.relays.size} relays`
|
||||
: Array.from(process.relays)
|
||||
.map((r) => r.url)
|
||||
.join(", ")}
|
||||
</Text>
|
||||
</Flex>
|
||||
{showChildren.isOpen &&
|
||||
Array.from(process.children)
|
||||
.filter((p) => (filter ? filter(p) : true))
|
||||
.map((child) => <ProcessBranch key={child.id} process={child} level={level + 1} filter={filter} />)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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<typeof createMemoryRouter>;
|
||||
|
||||
@ -30,18 +30,16 @@ const routes: RouteObject[] = [
|
||||
element: <TaskManagerLayout />,
|
||||
children: [
|
||||
{
|
||||
path: "network",
|
||||
element: <TaskManagerNetwork />,
|
||||
children: [
|
||||
{
|
||||
path: ":url",
|
||||
element: (
|
||||
<RouteProviders>
|
||||
<InspectRelayView />
|
||||
</RouteProviders>
|
||||
),
|
||||
},
|
||||
],
|
||||
path: "relays",
|
||||
element: <TaskManagerRelays />,
|
||||
},
|
||||
{
|
||||
path: "r/:url",
|
||||
element: <InspectRelayView />,
|
||||
},
|
||||
{
|
||||
path: "processes",
|
||||
element: <TaskManagerProcesses />,
|
||||
},
|
||||
{ path: "publish-log", element: <PublishLogView /> },
|
||||
{
|
||||
|
24
src/views/task-manager/relays/index.tsx
Normal file
24
src/views/task-manager/relays/index.tsx
Normal file
@ -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 (
|
||||
<Flex direction="column">
|
||||
{Array.from(relayPoolService.relays.values()).map((relay) => (
|
||||
<LinkBox key={relay.url} display="flex" gap="2" p="2" alignItems="center">
|
||||
<RelayFavicon relay={relay.url} size="sm" mr="2" />
|
||||
<HoverLinkOverlay as={RouterLink} to={`/r/${encodeURIComponent(relay.url)}`} isTruncated fontWeight="bold">
|
||||
{relay.url}
|
||||
</HoverLinkOverlay>
|
||||
<Spacer />
|
||||
<RelayStatus url={relay.url} />
|
||||
</LinkBox>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
64
src/views/task-manager/relays/inspect-relay.tsx
Normal file
64
src/views/task-manager/relays/inspect-relay.tsx
Normal file
@ -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 (
|
||||
<VerticalPageLayout>
|
||||
<Flex gap="2" alignItems="center" wrap="wrap">
|
||||
<BackButton size="sm" />
|
||||
<Heading size="md">{url}</Heading>
|
||||
<Spacer />
|
||||
<ButtonGroup size="sm">
|
||||
<RelayAuthButton relay={relay} />
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme={connecting ? "orange" : relay.connected ? "green" : "red"}
|
||||
onClick={connect}
|
||||
>
|
||||
{connecting ? "Connecting..." : relay.connected ? "Connected" : "Disconnected"}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column">
|
||||
{Array.from(rootProcesses).map((process) => (
|
||||
<ProcessBranch
|
||||
key={process.id}
|
||||
process={process}
|
||||
filter={(p) => (p.relays.size > 0 ? p.relays.has(relay) : p.children.size > 0)}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
@ -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"],
|
||||
|
16
yarn.lock
16
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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user