Show timelines, subscriptions, and services in task manager

This commit is contained in:
hzrd149 2024-05-01 12:48:11 -05:00
parent d20f6989f3
commit 7506141f54
73 changed files with 1003 additions and 499 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show timelines, subscriptions, and services in task manager

View File

@ -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",

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
},

View 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);
}
}

View File

@ -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();
}
}

View File

@ -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;

View File

@ -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;
}

View 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
View 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;
}
}

View File

@ -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;
}
}

View File

@ -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) {

View File

@ -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);
}
}

View File

@ -18,7 +18,7 @@ export default function EventKindsTable({
);
return (
<TableContainer>
<TableContainer minH="sm">
<Table size="sm">
<Thead>
<Tr>

View File

@ -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",

View File

@ -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;

View File

@ -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>

View File

@ -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>;
};

View 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;
}

View File

@ -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,

View File

@ -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

View File

@ -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[] = [];

View File

@ -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);
}

View File

@ -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) : []);

View File

@ -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
? {

View File

@ -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);

View File

@ -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
? {

View File

@ -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
? [

View File

@ -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;

View File

@ -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

View File

@ -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
? {

View File

@ -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>;
};

View File

@ -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;
}

View File

@ -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();
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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();

View File

@ -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]);

View 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;

View File

@ -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) {

View File

@ -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();

View File

@ -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;
}

View File

@ -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) {

View File

@ -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

View File

@ -27,7 +27,7 @@ class TimelineCacheService {
if (deadTimeline) {
this.log(`Destroying ${deadTimeline.name}`);
this.timelines.delete(deleteKey);
deadTimeline.cleanup();
deadTimeline.destroy();
}
}

View File

@ -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({

View File

@ -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;
}

View File

@ -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,

View File

@ -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],

View File

@ -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),
[
{

View File

@ -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";

View File

@ -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);

View File

@ -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,

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";
}

View File

@ -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>

View File

@ -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");

View File

@ -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>();

View File

@ -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>

View File

@ -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>;

View File

@ -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>
);
}

View File

@ -1,5 +0,0 @@
import VerticalPageLayout from "../../../components/vertical-page-layout";
export default function InspectRelayView() {
return <VerticalPageLayout></VerticalPageLayout>;
}

View 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>
);
}

View 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} />;
}

View 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} />)}
</>
);
}

View File

@ -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 /> },
{

View 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>
);
}

View 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>
);
}

View File

@ -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"],

View File

@ -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"