feat: UserState

This commit is contained in:
2024-04-22 14:38:14 +01:00
parent 5a7657a95d
commit 80a4b5d8e6
103 changed files with 4179 additions and 1165 deletions

View File

@ -5,7 +5,6 @@ import { EventEmitter } from "eventemitter3";
import { Connection, RelaySettings } from "./connection";
import { NostrEvent, OkResponse, TaggedNostrEvent } from "./nostr";
import { SystemInterface } from ".";
import LRUSet from "@snort/shared/src/LRUSet";
export interface NostrConnectionPoolEvents {
connected: (address: string, wasReconnect: boolean) => void;
@ -38,7 +37,6 @@ export class DefaultConnectionPool extends EventEmitter<NostrConnectionPoolEvent
* All currently connected websockets
*/
#sockets = new Map<string, Connection>();
#requestedIds = new LRUSet<string>(1000);
constructor(system: SystemInterface) {
super();
@ -49,7 +47,8 @@ export class DefaultConnectionPool extends EventEmitter<NostrConnectionPoolEvent
* Get a connection object from the pool
*/
getConnection(id: string) {
return this.#sockets.get(id);
const addr = unwrap(sanitizeRelayUrl(id));
return this.#sockets.get(addr);
}
/**

View File

@ -51,6 +51,9 @@ export class Connection extends EventEmitter<ConnectionEvents> {
#activity: number = unixNowMs();
#expectAuth = false;
#ephemeral: boolean;
#closing = false;
#downCount = 0;
#activeRequests = new Set<string>();
Id: string;
readonly Address: string;
@ -58,26 +61,22 @@ export class Connection extends EventEmitter<ConnectionEvents> {
PendingRaw: Array<object> = [];
PendingRequests: Array<ConnectionQueueItem> = [];
ActiveRequests = new Set<string>();
Settings: RelaySettings;
Info?: RelayInfo;
ConnectTimeout: number = DefaultConnectTimeout;
HasStateChange: boolean = true;
IsClosed: boolean;
ReconnectTimer?: ReturnType<typeof setTimeout>;
EventsCallback: Map<u256, (msg: Array<string | boolean>) => void>;
AwaitingAuth: Map<string, boolean>;
Authed = false;
Down = true;
constructor(addr: string, options: RelaySettings, ephemeral: boolean = false) {
super();
this.Id = uuid();
this.Address = addr;
this.Settings = options;
this.IsClosed = false;
this.EventsCallback = new Map();
this.AwaitingAuth = new Map();
this.#ephemeral = ephemeral;
@ -93,7 +92,20 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.#setupEphemeral();
}
get isOpen() {
return this.Socket?.readyState === WebSocket.OPEN;
}
get isDown() {
return this.#downCount > 0;
}
get ActiveRequests() {
return [...this.#activeRequests];
}
async connect() {
if (this.isOpen) return;
try {
if (this.Info === undefined) {
const u = new URL(this.Address);
@ -116,7 +128,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
// ignored
}
const wasReconnect = this.Socket !== null && !this.IsClosed;
const wasReconnect = this.Socket !== null;
if (this.Socket) {
this.Id = uuid();
this.Socket.onopen = null;
@ -125,7 +137,6 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.Socket.onclose = null;
this.Socket = null;
}
this.IsClosed = false;
this.Socket = new WebSocket(this.Address);
this.Socket.onopen = () => this.#onOpen(wasReconnect);
this.Socket.onmessage = e => this.#onMessage(e);
@ -133,52 +144,57 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.Socket.onclose = e => this.#onClose(e);
}
close() {
this.IsClosed = true;
close(final = true) {
if (final) {
this.#closing = true;
}
this.Socket?.close();
}
#onOpen(wasReconnect: boolean) {
this.ConnectTimeout = DefaultConnectTimeout;
this.#downCount = 0;
this.#log(`Open!`);
this.Down = false;
this.#setupEphemeral();
this.emit("connected", wasReconnect);
this.#sendPendingRaw();
}
#onClose(e: WebSocket.CloseEvent) {
if (this.ReconnectTimer) {
clearTimeout(this.ReconnectTimer);
this.ReconnectTimer = undefined;
}
// remote server closed the connection, dont re-connect
if (e.code === 4000) {
this.IsClosed = true;
this.#log(`Closed! (Remote)`);
} else if (!this.IsClosed) {
this.ConnectTimeout = this.ConnectTimeout * this.ConnectTimeout;
this.#log(
`Closed (code=${e.code}), trying again in ${(this.ConnectTimeout / 1000).toFixed(0).toLocaleString()} sec`,
);
this.ReconnectTimer = setTimeout(() => {
try {
this.connect();
} catch {
this.emit("disconnect", -1);
}
}, this.ConnectTimeout);
// todo: stats disconnect
if (!this.#closing) {
this.#downCount++;
this.#reconnectTimer(e);
} else {
this.#log(`Closed!`);
this.ReconnectTimer = undefined;
this.#downCount = 0;
if (this.ReconnectTimer) {
clearTimeout(this.ReconnectTimer);
this.ReconnectTimer = undefined;
}
}
this.emit("disconnect", e.code);
this.#reset();
}
#reconnectTimer(e: WebSocket.CloseEvent) {
if (this.ReconnectTimer) {
clearTimeout(this.ReconnectTimer);
this.ReconnectTimer = undefined;
}
this.ConnectTimeout = this.ConnectTimeout * 2;
this.#log(
`Closed (code=${e.code}), trying again in ${(this.ConnectTimeout / 1000).toFixed(0).toLocaleString()} sec`,
);
this.ReconnectTimer = setTimeout(() => {
try {
this.connect();
} catch {
this.emit("disconnect", -1);
}
}, this.ConnectTimeout);
}
#onMessage(e: WebSocket.MessageEvent) {
this.#activity = unixNowMs();
if ((e.data as string).length > 0) {
@ -332,14 +348,13 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.#expectAuth = true;
this.#log("Setting expectAuth flag %o", requestKinds);
}
if (this.ActiveRequests.size >= this.#maxSubscriptions) {
if (this.#activeRequests.size >= this.#maxSubscriptions) {
this.PendingRequests.push({
obj: cmd,
cb: cbSent,
});
this.#log("Queuing: %O", cmd);
} else {
this.ActiveRequests.add(cmd[1]);
this.#sendRequestCommand({
obj: cmd,
cb: cbSent,
@ -350,7 +365,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
}
closeReq(id: string) {
if (this.ActiveRequests.delete(id)) {
if (this.#activeRequests.delete(id)) {
this.send(["CLOSE", id]);
this.emit("eose", id);
this.#sendQueuedRequests();
@ -359,7 +374,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
}
#sendQueuedRequests() {
const canSend = this.#maxSubscriptions - this.ActiveRequests.size;
const canSend = this.#maxSubscriptions - this.#activeRequests.size;
if (canSend > 0) {
for (let x = 0; x < canSend; x++) {
const p = this.PendingRequests.shift();
@ -375,12 +390,12 @@ export class Connection extends EventEmitter<ConnectionEvents> {
try {
const cmd = item.obj;
if (cmd[0] === "REQ" || cmd[0] === "GET") {
this.ActiveRequests.add(cmd[1]);
this.#activeRequests.add(cmd[1]);
this.send(cmd);
} else if (cmd[0] === "SYNC") {
const [_, id, eventSet, ...filters] = cmd;
const lastResortSync = () => {
if (filters.some(a => a.since || a.until)) {
if (filters.some(a => a.since || a.until || a.ids)) {
this.queueReq(["REQ", id, ...filters], item.cb);
} else {
const latest = eventSet.reduce((acc, v) => (acc = v.created_at > acc ? v.created_at : acc), 0);
@ -391,7 +406,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.queueReq(["REQ", id, ...newFilters], item.cb);
}
};
if (this.Address.startsWith("wss://relay.snort.social")) {
if (this.Info?.negentropy === "v1") {
const newFilters = filters;
const neg = new NegentropyFlow(id, this, eventSet, newFilters);
neg.once("finish", filters => {
@ -419,19 +434,34 @@ export class Connection extends EventEmitter<ConnectionEvents> {
// reset connection Id on disconnect, for query-tracking
this.Id = uuid();
this.#expectAuth = false;
this.ActiveRequests.clear();
this.#log(
"Reset active=%O, pending=%O, raw=%O",
[...this.#activeRequests],
[...this.PendingRequests],
[...this.PendingRaw],
);
for (const active of this.#activeRequests) {
this.emit("closed", active, "connection closed");
}
for (const pending of this.PendingRequests) {
this.emit("closed", pending.obj[1], "connection closed");
}
for (const raw of this.PendingRaw) {
if (Array.isArray(raw) && raw[0] === "REQ") {
this.emit("closed", raw[1], "connection closed");
}
}
this.#activeRequests.clear();
this.PendingRequests = [];
this.PendingRaw = [];
this.emit("change");
}
send(obj: object) {
const authPending = !this.Authed && (this.AwaitingAuth.size > 0 || this.Info?.limitation?.auth_required === true);
if (!this.Socket || this.Socket?.readyState !== WebSocket.OPEN || authPending) {
if (!this.isOpen || authPending) {
this.PendingRaw.push(obj);
if (this.Socket?.readyState === WebSocket.CLOSED && this.Ephemeral && this.IsClosed) {
this.connect();
}
return false;
}
@ -503,11 +533,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
if (this.Ephemeral) {
this.#ephemeralCheck = setInterval(() => {
const lastActivity = unixNowMs() - this.#activity;
if (lastActivity > 30_000 && !this.IsClosed) {
if (this.ActiveRequests.size > 0) {
this.#log("Inactive connection has %d active requests! %O", this.ActiveRequests.size, this.ActiveRequests);
if (lastActivity > 30_000 && !this.#closing) {
if (this.#activeRequests.size > 0) {
this.#log(
"Inactive connection has %d active requests! %O",
this.#activeRequests.size,
this.#activeRequests,
);
} else {
this.close();
this.close(false);
}
}
}, 5_000);

View File

@ -1,4 +1,4 @@
import { EventKind, HexKey, NostrPrefix, NostrEvent, EventSigner, PowMiner } from ".";
import { EventKind, HexKey, NostrPrefix, NostrEvent, EventSigner, PowMiner, NotSignedNostrEvent } from ".";
import { HashtagRegex, MentionNostrEntityRegex } from "./const";
import { getPublicKey, jitter, unixNow } from "@snort/shared";
import { EventExt } from "./event-ext";
@ -14,6 +14,10 @@ export class EventBuilder {
#powMiner?: PowMiner;
#jitter?: number;
get pubkey() {
return this.#pubkey;
}
/**
* Populate builder with values from link
*/

View File

@ -11,6 +11,7 @@ export interface Tag {
value?: string;
relay?: string;
marker?: string; // NIP-10
author?: string; // NIP-10 "pubkey-stub"
}
export interface Thread {
@ -48,9 +49,6 @@ export abstract class EventExt {
const sig = secp.schnorr.sign(e.id, key);
e.sig = utils.bytesToHex(sig);
if (!secp.schnorr.verify(e.sig, e.id, e.pubkey)) {
throw new Error("Signing failed");
}
return e;
}
@ -102,10 +100,15 @@ export abstract class EventExt {
value: tag[1],
} as Tag;
switch (ret.key) {
case "a":
case "a": {
ret.relay = tag[2];
ret.marker = tag[3];
break;
}
case "e": {
ret.relay = tag.length > 2 ? tag[2] : undefined;
ret.marker = tag.length > 3 ? tag[3] : undefined;
ret.relay = tag[2];
ret.marker = tag[3];
ret.author = tag[4];
break;
}
}

View File

@ -15,6 +15,7 @@ import {
PowMiner,
PrivateKeySigner,
RelaySettings,
settingsToRelayTag,
SignerSupports,
TaggedNostrEvent,
ToNostrEventTag,
@ -23,10 +24,10 @@ import {
} from ".";
import { EventBuilder } from "./event-builder";
import { EventExt } from "./event-ext";
import { findTag } from "./utils";
import { Nip7Signer } from "./impl/nip7";
import { base64 } from "@scure/base";
import { Nip10 } from "./impl/nip10";
type EventBuilderHook = (ev: EventBuilder) => EventBuilder;
@ -202,29 +203,7 @@ export class EventPublisher {
const eb = this.#eb(EventKind.TextNote);
eb.content(msg);
const link = NostrLink.fromEvent(replyTo);
const thread = EventExt.extractThread(replyTo);
if (thread) {
const rootOrReplyAsRoot = thread.root || thread.replyTo;
if (rootOrReplyAsRoot) {
eb.tag([rootOrReplyAsRoot.key, rootOrReplyAsRoot.value ?? "", rootOrReplyAsRoot.relay ?? "", "root"]);
}
eb.tag([...unwrap(link.toEventTag()), "reply"]);
eb.tag(["p", replyTo.pubkey]);
for (const pk of thread.pubKeys) {
if (pk === this.#pubKey) {
continue;
}
eb.tag(["p", pk]);
}
} else {
eb.tag([...unwrap(link.toEventTag()), "root"]);
// dont tag self in replies
if (replyTo.pubkey !== this.#pubKey) {
eb.tag(["p", replyTo.pubkey]);
}
}
Nip10.replyTo(replyTo, eb);
eb.processContent();
fnExtra?.(eb);
return await this.#sign(eb);
@ -247,15 +226,9 @@ export class EventPublisher {
}
const eb = this.#eb(EventKind.Relays);
for (const rx of relays) {
const rTag = ["r", rx.url];
if (rx.settings.read && !rx.settings.write) {
rTag.push("read");
}
if (rx.settings.write && !rx.settings.read) {
rTag.push("write");
}
if (rx.settings.read || rx.settings.write) {
eb.tag(rTag);
const tag = settingsToRelayTag(rx);
if (tag) {
eb.tag(tag);
}
}
return await this.#sign(eb);

View File

@ -0,0 +1,73 @@
import { dedupe, unwrap } from "@snort/shared";
import { EventBuilder } from "../event-builder";
import { NostrEvent } from "../nostr";
import { NostrLink } from "../nostr-link";
export interface Nip10Thread {
root?: NostrLink;
replyTo?: NostrLink;
mentions: Array<NostrLink>;
pubKeys: Array<NostrLink>;
}
/**
* Utility class which exports functions used in NIP-10
*/
export class Nip10 {
/**
* Reply to an event using NIP-10 tagging
*/
static replyTo(ev: NostrEvent, eb: EventBuilder) {
const link = NostrLink.fromEvent(ev);
const thread = Nip10.parseThread(ev);
if (thread) {
const rootOrReplyAsRoot = thread.root || thread.replyTo;
if (rootOrReplyAsRoot) {
eb.tag(unwrap(rootOrReplyAsRoot.toEventTag("root")));
}
eb.tag(unwrap(link.toEventTag("reply")));
for (const pk of thread.pubKeys) {
if (pk.id === eb.pubkey) {
continue;
}
eb.tag(unwrap(pk.toEventTag()));
}
} else {
eb.tag(unwrap(link.toEventTag("root")));
if (ev.pubkey !== eb.pubkey) {
eb.tag(["p", ev.pubkey]);
}
}
}
static parseThread(ev: NostrEvent) {
const ret = {
mentions: [],
pubKeys: [],
} as Nip10Thread;
const replyTags = ev.tags.filter(a => a[0] === "e" || a[0] === "a").map(a => NostrLink.fromTag(a));
if (replyTags.length > 0) {
const marked = replyTags.some(a => a.marker);
if (!marked) {
ret.root = replyTags[0];
if (replyTags.length > 1) {
ret.replyTo = replyTags[replyTags.length - 1];
}
if (replyTags.length > 2) {
ret.mentions = replyTags.slice(1, -1);
}
} else {
const root = replyTags.find(a => a.marker === "root");
const reply = replyTags.find(a => a.marker === "reply");
ret.root = root;
ret.replyTo = reply;
ret.mentions = replyTags.filter(a => a.marker === "mention");
}
} else {
return undefined;
}
ret.pubKeys = dedupe(ev.tags.filter(a => a[0] === "p").map(a => a[1])).map(a => NostrLink.publicKey(a));
return ret;
}
}

View File

@ -1,10 +1,10 @@
import { decodeInvoice, InvoiceDetails } from "@snort/shared";
import { NostrEvent } from "./nostr";
import { findTag } from "./utils";
import { EventExt } from "./event-ext";
import { NostrLink } from "./nostr-link";
import debug from "debug";
import { LRUCache } from "lru-cache";
import { decodeInvoice, InvoiceDetails } from "@snort/shared";
import { NostrEvent } from "../nostr";
import { findTag } from "../utils";
import { NostrLink } from "../nostr-link";
import { Nip10 } from "./nip10";
const Log = debug("zaps");
const ParsedZapCache = new LRUCache<string, ParsedZap>({ max: 1000 });
@ -35,7 +35,7 @@ export function parseZap(zapReceipt: NostrEvent): ParsedZap {
// old format, ignored
throw new Error("deprecated zap format");
}
const zapRequestThread = EventExt.extractThread(zapRequest);
const zapRequestThread = Nip10.parseThread(zapRequest);
const requestContext = zapRequestThread?.root;
const anonZap = zapRequest.tags.find(a => a[0] === "anon");
@ -44,7 +44,7 @@ export function parseZap(zapReceipt: NostrEvent): ParsedZap {
id: zapReceipt.id,
zapService: zapReceipt.pubkey,
amount: (invoice?.amount ?? 0) / 1000,
event: requestContext ? NostrLink.fromThreadTag(requestContext) : undefined,
event: requestContext,
sender: zapRequest.pubkey,
receiver: findTag(zapRequest, "p"),
valid: true,

View File

@ -30,7 +30,7 @@ export * from "./event-publisher";
export * from "./event-builder";
export * from "./nostr-link";
export * from "./profile-cache";
export * from "./zaps";
export * from "./impl/nip57";
export * from "./signer";
export * from "./text";
export * from "./pow";
@ -39,11 +39,14 @@ export * from "./query-optimizer";
export * from "./encrypted";
export * from "./outbox";
export * from "./sync";
export * from "./user-state";
export * from "./impl/nip4";
export * from "./impl/nip44";
export * from "./impl/nip7";
export * from "./impl/nip10";
export * from "./impl/nip44";
export * from "./impl/nip46";
export * from "./impl/nip57";
export * from "./cache/index";
export * from "./cache/user-relays";

View File

@ -19,7 +19,7 @@ export interface ToNostrEventTag {
export class NostrHashtagLink implements ToNostrEventTag {
constructor(readonly tag: string) {}
toEventTag(): string[] | undefined {
toEventTag() {
return ["t", this.tag];
}
}
@ -31,6 +31,7 @@ export class NostrLink implements ToNostrEventTag {
readonly kind?: number,
readonly author?: string,
readonly relays?: Array<string>,
readonly marker?: string,
) {
if (type !== NostrPrefix.Address && !isHex(id)) {
throw new Error("ID must be hex");
@ -52,22 +53,43 @@ export class NostrLink implements ToNostrEventTag {
}
}
toEventTag(marker?: string) {
const relayEntry = this.relays?.at(0) ? [this.relays[0]] : [];
get tagKey() {
if (this.type === NostrPrefix.Address) {
return `${this.kind}:${this.author}:${this.id}`;
}
return this.id;
}
/**
* Create an event tag for this link
*/
toEventTag(marker?: string) {
const suffix: Array<string> = [];
if (this.relays && this.relays.length > 0) {
suffix.push(this.relays[0]);
}
if (marker) {
if (relayEntry.length === 0) {
relayEntry.push("");
if (suffix[0] === undefined) {
suffix.push(""); // empty relay hint
}
relayEntry.push(marker);
suffix.push(marker);
}
if (this.type === NostrPrefix.PublicKey || this.type === NostrPrefix.Profile) {
return ["p", this.id, ...relayEntry];
return ["p", this.id, ...suffix];
} else if (this.type === NostrPrefix.Note || this.type === NostrPrefix.Event) {
return ["e", this.id, ...relayEntry];
if (this.author) {
if (suffix[0] === undefined) {
suffix.push(""); // empty relay hint
}
if (suffix[1] === undefined) {
suffix.push(""); // empty marker
}
suffix.push(this.author);
}
return ["e", this.id, ...suffix];
} else if (this.type === NostrPrefix.Address) {
return ["a", `${this.kind}:${this.author}:${this.id}`, ...relayEntry];
return ["a", `${this.kind}:${this.author}:${this.id}`, ...suffix];
}
}
@ -162,46 +184,38 @@ export class NostrLink implements ToNostrEventTag {
}
}
static fromThreadTag(tag: Tag) {
const relay = tag.relay ? [tag.relay] : undefined;
switch (tag.key) {
case "e": {
return new NostrLink(NostrPrefix.Event, unwrap(tag.value), undefined, undefined, relay);
}
case "p": {
return new NostrLink(NostrPrefix.Profile, unwrap(tag.value), undefined, undefined, relay);
}
case "a": {
const [kind, author, dTag] = unwrap(tag.value).split(":");
return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relay);
}
}
throw new Error(`Unknown tag kind ${tag.key}`);
}
static fromTag(tag: Array<string>, author?: string, kind?: number) {
static fromTag<T = NostrLink>(
tag: Array<string>,
author?: string,
kind?: number,
fnOther?: (tag: Array<string>) => T,
) {
const relays = tag.length > 2 ? [tag[2]] : undefined;
switch (tag[0]) {
case "e": {
return new NostrLink(NostrPrefix.Event, tag[1], kind, author, relays);
return new NostrLink(NostrPrefix.Event, tag[1], kind, author ?? tag[4], relays, tag[3]);
}
case "p": {
return new NostrLink(NostrPrefix.Profile, tag[1], kind, author, relays);
}
case "a": {
const [kind, author, dTag] = tag[1].split(":");
return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relays);
return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relays, tag[3]);
}
default: {
if (fnOther) {
return fnOther(tag);
}
}
}
throw new Error(`Unknown tag kind ${tag[0]}`);
}
static fromTags(tags: Array<Array<string>>) {
static fromTags<T = NostrLink>(tags: ReadonlyArray<Array<string>>, fnOther?: (tag: Array<string>) => T) {
return removeUndefined(
tags.map(a => {
try {
return NostrLink.fromTag(a);
return NostrLink.fromTag<T>(a, undefined, undefined, fnOther);
} catch {
// ignored, cant be mapped
}
@ -218,6 +232,14 @@ export class NostrLink implements ToNostrEventTag {
}
return new NostrLink(NostrPrefix.Event, ev.id, ev.kind, ev.pubkey, relays);
}
static profile(pk: string, relays?: Array<string>) {
return new NostrLink(NostrPrefix.Profile, pk, undefined, undefined, relays);
}
static publicKey(pk: string, relays?: Array<string>) {
return new NostrLink(NostrPrefix.PublicKey, pk, undefined, undefined, relays);
}
}
export function validateNostrLink(link: string): boolean {

View File

@ -70,8 +70,7 @@ export class KeyedReplaceableNoteStore extends HookedNoteStore {
ev.forEach(a => {
const keyOnEvent = this.#keyFn(a);
const existing = this.#events.get(keyOnEvent);
const existingCreated = existing?.created_at ?? 0;
if (a.created_at > existingCreated) {
if (a.created_at > (existing?.created_at ?? 0)) {
if (existing) {
a.relays = dedupe([...existing.relays, ...a.relays]);
}

View File

@ -1,5 +1,5 @@
import { EventKind, FullRelaySettings, NostrEvent, SystemInterface, UsersRelays } from "..";
import { sanitizeRelayUrl } from "@snort/shared";
import { removeUndefined, sanitizeRelayUrl } from "@snort/shared";
export const DefaultPickNRelays = 2;
@ -20,6 +20,7 @@ export type EventFetcher = {
};
export function parseRelayTag(tag: Array<string>) {
if (tag[0] !== "r") return;
return {
url: sanitizeRelayUrl(tag[1]),
settings: {
@ -30,7 +31,7 @@ export function parseRelayTag(tag: Array<string>) {
}
export function parseRelayTags(tag: Array<Array<string>>) {
return tag.map(parseRelayTag).filter(a => a !== null);
return removeUndefined(tag.map(parseRelayTag));
}
export function parseRelaysFromKind(ev: NostrEvent) {
@ -54,5 +55,21 @@ export function parseRelaysFromKind(ev: NostrEvent) {
}
}
/**
* Convert relay settings into NIP-65 relay tag
*/
export function settingsToRelayTag(rx: FullRelaySettings) {
const rTag = ["r", rx.url];
if (rx.settings.read && !rx.settings.write) {
rTag.push("read");
}
if (rx.settings.write && !rx.settings.read) {
rTag.push("write");
}
if (rx.settings.read || rx.settings.write) {
return rTag;
}
}
export * from "./outbox-model";
export * from "./relay-loader";

View File

@ -182,6 +182,16 @@ export class OutboxModel extends BaseRequestRouter {
return ret;
}
async forReplyTo(pk: string, pickN?: number | undefined): Promise<string[]> {
const recipients = [pk];
await this.updateRelayLists(recipients);
const relays = this.pickTopRelays(recipients, pickN ?? DefaultPickNRelays, "read");
const ret = removeUndefined(dedupe(relays.map(a => a.relays).flat()));
this.#log("Picked: pattern=%s, input=%s, output=%O", "inbox", pk, ret);
return ret;
}
/**
* Update relay cache with latest relay lists
* @param authors The authors to update relay lists for

View File

@ -4,6 +4,7 @@ import { BuiltRawReqFilter, RequestBuilder, SystemInterface, TaggedNostrEvent }
import { Query, TraceReport } from "./query";
import { FilterCacheLayer } from "./filter-cache-layer";
import { trimFilters } from "./request-trim";
import { eventMatchesFilter } from "./request-matcher";
interface QueryManagerEvents {
change: () => void;
@ -75,26 +76,19 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
* Async fetch results
*/
async fetch(req: RequestBuilder, cb?: (evs: Array<TaggedNostrEvent>) => void) {
const q = new Query(this.#system, req);
q.on("trace", r => this.emit("trace", r));
q.on("request", (subId, fx) => {
this.#send(q, fx);
});
const filters = req.buildRaw();
const q = this.query(req);
if (cb) {
q.on("event", evs => cb(evs));
q.on("event", cb);
}
await new Promise<void>(resolve => {
q.on("loading", loading => {
this.#log("loading %s %o", q.id, loading);
if (!loading) {
resolve();
}
});
q.once("done", resolve);
});
const results = q.feed.takeSnapshot();
q.cleanup();
this.#log("Fetch results for %s %o", q.id, results);
return results;
if (cb) {
q.off("event", cb);
}
return results.filter(a => filters.some(b => eventMatchesFilter(a, b)));
}
*[Symbol.iterator]() {
@ -112,6 +106,7 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
const data = await this.#system.cacheRelay.query(["REQ", q.id, ...qSend.filters]);
if (data.length > 0) {
qSend.syncFrom = data as Array<TaggedNostrEvent>;
this.#log("Adding from cache: %O", data);
q.feed.add(data as Array<TaggedNostrEvent>);
}
}
@ -138,6 +133,8 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
const qt = q.sendToRelay(s, qSend);
if (qt) {
return [qt];
} else {
this.#log("Query not sent to %s: %O", qSend.relay, qSend);
}
} else {
const nc = await this.#system.pool.connect(qSend.relay, { read: true, write: true }, true);
@ -145,6 +142,8 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
const qt = q.sendToRelay(nc, qSend);
if (qt) {
return [qt];
} else {
this.#log("Query not sent to %s: %O", qSend.relay, qSend);
}
} else {
console.warn("Failed to connect to new relay for:", qSend.relay, q);
@ -158,6 +157,8 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
const qt = q.sendToRelay(s, qSend);
if (qt) {
ret.push(qt);
} else {
this.#log("Query not sent to %s: %O", a, qSend);
}
}
}

View File

@ -99,11 +99,11 @@ export interface TraceReport {
}
export interface QueryEvents {
loading: (v: boolean) => void;
trace: (report: TraceReport) => void;
request: (subId: string, req: BuiltRawReqFilter) => void;
event: (evs: Array<TaggedNostrEvent>) => void;
end: () => void;
done: () => void;
}
const QueryCache = new LRUCache<string, Array<TaggedNostrEvent>>({
@ -357,7 +357,7 @@ export class Query extends EventEmitter<QueryEvents> {
const isFinished = this.progress === 1;
if (isFinished) {
this.#log("%s loading=%s, progress=%d, traces=%O", this.id, !isFinished, this.progress, this.#tracing);
this.emit("loading", !isFinished);
this.emit("done");
}
}
@ -384,6 +384,10 @@ export class Query extends EventEmitter<QueryEvents> {
if (q.relay && q.relay !== c.Address) {
return false;
}
// connection is down, dont send
if (c.isDown) {
return false;
}
// cannot send unless relay is tagged on ephemeral relay connection
if (!q.relay && c.Ephemeral) {
this.#log("Cant send non-specific REQ to ephemeral connection %O %O %O", q, q.relay, c);

View File

@ -18,4 +18,5 @@ export interface RelayInfo {
language_tags?: Array<string>;
tags?: Array<string>;
posting_policy?: string;
negentropy?: string;
}

View File

@ -3,7 +3,7 @@ import { v4 as uuid } from "uuid";
import { appendDedupe, dedupe, removeUndefined, sanitizeRelayUrl, unixNowMs, unwrap } from "@snort/shared";
import EventKind from "./event-kind";
import { FlatReqFilter, NostrLink, NostrPrefix, SystemInterface } from ".";
import { FlatReqFilter, NostrLink, NostrPrefix, SystemInterface, ToNostrEventTag } from ".";
import { ReqFilter, u256, HexKey, TaggedNostrEvent } from "./nostr";
import { RequestRouter } from "./request-router";
@ -230,6 +230,19 @@ export class RequestFilterBuilder {
return this;
}
/**
* Query by a nostr tag
*/
tags(tags: Array<ToNostrEventTag>) {
for (const tag of tags) {
const tt = tag.toEventTag();
if (tt) {
this.tag(tt[0], [tt[1]]);
}
}
return this;
}
search(keyword?: string) {
if (!keyword) return this;
this.#filter.search = keyword;

View File

@ -6,6 +6,15 @@ import { FlatReqFilter } from "./query-optimizer";
* Request router managed splitting of requests to one or more relays, and which relay to send events to.
*/
export interface RequestRouter {
/**
* Pick relays to send an event to
* @param pk The pubkey you are replying to
* @param system Nostr system interface
* @param pickN Number of relays to pick per recipient
* @returns
*/
forReplyTo(pk: string, pickN?: number): Promise<Array<string>>;
/**
* Pick relays to send an event to
* @param ev The reply event to send
@ -39,6 +48,7 @@ export interface RequestRouter {
}
export abstract class BaseRequestRouter implements RequestRouter {
abstract forReplyTo(pk: string, pickN?: number): Promise<Array<string>>;
abstract forReply(ev: NostrEvent, pickN?: number): Promise<Array<string>>;
abstract forRequest(filter: ReqFilter, pickN?: number): Array<ReqFilter>;
abstract forFlatRequest(filter: FlatReqFilter[], pickN?: number): Array<FlatReqFilter>;

View File

@ -4,7 +4,7 @@ import { EventExt } from "./event-ext";
import { Nip4WebCryptoEncryptor } from "./impl/nip4";
import { XChaCha20Encryptor } from "./impl/nip44";
import { MessageEncryptorVersion, decodeEncryptionPayload, encodeEncryptionPayload } from "./index";
import { NostrEvent } from "./nostr";
import { NostrEvent, NotSignedNostrEvent } from "./nostr";
import { base64 } from "@scure/base";
export type SignerSupports = "nip04" | "nip44" | string;
@ -16,7 +16,7 @@ export interface EventSigner {
nip4Decrypt(content: string, otherKey: string): Promise<string>;
nip44Encrypt(content: string, key: string): Promise<string>;
nip44Decrypt(content: string, otherKey: string): Promise<string>;
sign(ev: NostrEvent): Promise<NostrEvent>;
sign(ev: NostrEvent | NotSignedNostrEvent): Promise<NostrEvent>;
get supports(): Array<SignerSupports>;
}

View File

@ -1,100 +1,210 @@
import { EventBuilder, EventSigner, NostrLink, SystemInterface } from "..";
import { SafeSync } from "./safe-sync";
import { EventEmitter } from "eventemitter3";
import { EventBuilder, EventSigner, NostrEvent, NostrLink, NotSignedNostrEvent, SystemInterface, Tag } from "..";
import { SafeSync, SafeSyncEvents } from "./safe-sync";
import debug from "debug";
interface TagDiff {
type: "add" | "remove" | "replace";
type: "add" | "remove" | "replace" | "update";
tag: Array<string> | Array<Array<string>>;
}
/**
* Add/Remove tags from event
*/
export class DiffSyncTags {
export class DiffSyncTags extends EventEmitter<SafeSyncEvents> {
#log = debug("DiffSyncTags");
#sync = new SafeSync();
#sync: SafeSync;
#changes: Array<TagDiff> = [];
#changesEncrypted: Array<TagDiff> = [];
#decryptedContent?: string;
constructor(readonly link: NostrLink) {}
constructor(readonly link: NostrLink) {
super();
this.#sync = new SafeSync(link);
this.#sync.on("change", () => {
this.emit("change");
});
}
/**
* Get the raw storage event
*/
get value() {
return this.#sync.value;
}
/**
* Get the current tag set
*/
get tags() {
const next = this.#nextEvent();
return next.tags;
}
/**
* Get decrypted content
*/
get encryptedTags() {
if (this.#decryptedContent) {
const tags = JSON.parse(this.#decryptedContent) as Array<Array<string>>;
return tags;
}
return [];
}
/**
* Add a tag
*/
add(tag: Array<string> | Array<Array<string>>) {
this.#changes.push({
add(tag: Array<string> | Array<Array<string>>, encrypted = false) {
(encrypted ? this.#changesEncrypted : this.#changes).push({
type: "add",
tag,
});
this.emit("change");
}
/**
* Remove a tag
*/
remove(tag: Array<string> | Array<Array<string>>) {
this.#changes.push({
remove(tag: Array<string> | Array<Array<string>>, encrypted = false) {
(encrypted ? this.#changesEncrypted : this.#changes).push({
type: "remove",
tag,
});
this.emit("change");
}
/**
* Update a tag (remove+add)
*/
update(tag: Array<string> | Array<Array<string>>, encrypted = false) {
(encrypted ? this.#changesEncrypted : this.#changes).push({
type: "update",
tag,
});
this.emit("change");
}
/**
* Replace all the tags
*/
replace(tag: Array<Array<string>>) {
this.#changes.push({
replace(tag: Array<Array<string>>, encrypted = false) {
(encrypted ? this.#changesEncrypted : this.#changes).push({
type: "replace",
tag,
});
this.emit("change");
}
async sync(signer: EventSigner, system: SystemInterface) {
await this.#sync.sync(system);
if (
this.#sync.value?.content &&
this.#sync.value?.content.startsWith("[") &&
this.#sync.value?.content.endsWith("]")
) {
const decrypted = await signer.nip4Decrypt(this.#sync.value.content, await signer.getPubKey());
this.#decryptedContent = decrypted;
}
}
/**
* Apply changes and save
*/
async persist(signer: EventSigner, system: SystemInterface, content?: string) {
const cloneChanges = [...this.#changes];
this.#changes = [];
if (!this.#sync.didSync) {
await this.sync(signer, system);
}
// always start with sync
const res = await this.#sync.sync(this.link, system);
const isNew = this.#sync.value === undefined;
const next = this.#nextEvent(content);
// content is populated as tags, encrypt it
if (next.content.length > 0 && !content) {
next.content = await signer.nip4Encrypt(next.content, await signer.getPubKey());
}
await this.#sync.update(next, signer, system, !isNew);
}
#nextEvent(content?: string): NotSignedNostrEvent {
if (content !== undefined && this.#changesEncrypted.length > 0) {
throw new Error("Cannot have both encrypted tags and explicit content");
}
let isNew = false;
let next = res ? { ...res } : undefined;
let next = this.#sync.value ? { ...this.#sync.value } : undefined;
if (!next) {
const eb = new EventBuilder();
eb.fromLink(this.link);
next = eb.build();
isNew = true;
}
if (content) {
// apply changes onto next
this.#applyChanges(next.tags, this.#changes);
if (this.#changesEncrypted.length > 0 && !content) {
const encryptedTags = isNew ? [] : this.encryptedTags;
this.#applyChanges(encryptedTags, this.#changesEncrypted);
next.content = JSON.stringify(encryptedTags);
} else if (content) {
next.content = content;
}
// apply changes onto next
for (const change of cloneChanges) {
for (const changeTag of Array.isArray(change.tag[0])
? (change.tag as Array<Array<string>>)
: [change.tag as Array<string>]) {
const existing = next.tags.findIndex(a => a.every((b, i) => changeTag[i] === b));
switch (change.type) {
case "add": {
return next;
}
#applyChanges(tags: Array<Array<string>>, changes: Array<TagDiff>) {
for (const change of changes) {
if (change.tag.length === 0 && change.type !== "replace") continue;
switch (change.type) {
case "add": {
const changeTags = Array.isArray(change.tag[0])
? (change.tag as Array<Array<string>>)
: [change.tag as Array<string>];
for (const changeTag of changeTags) {
const existing = tags.findIndex(a => change.tag[0] === a[0] && change.tag[1] === a[1]);
if (existing === -1) {
next.tags.push(changeTag);
tags.push(changeTag);
} else {
this.#log("Tag already exists: %O", changeTag);
}
break;
}
case "remove": {
break;
}
case "remove": {
const changeTags = Array.isArray(change.tag[0])
? (change.tag as Array<Array<string>>)
: [change.tag as Array<string>];
for (const changeTag of changeTags) {
const existing = tags.findIndex(a => change.tag[0] === a[0] && change.tag[1] === a[1]);
if (existing !== -1) {
next.tags.splice(existing, 1);
tags.splice(existing, 1);
} else {
this.#log("Could not find tag to remove: %O", changeTag);
}
}
break;
}
case "update": {
const changeTags = Array.isArray(change.tag[0])
? (change.tag as Array<Array<string>>)
: [change.tag as Array<string>];
for (const changeTag of changeTags) {
const existing = tags.findIndex(a => change.tag[0] === a[0] && change.tag[1] === a[1]);
if (existing !== -1) {
tags[existing] = changeTag;
} else {
this.#log("Could not find tag to update: %O", changeTag);
}
}
break;
}
case "replace": {
tags.splice(0, tags.length);
tags.push(...(change.tag as Array<Array<string>>));
break;
}
}
}
await this.#sync.update(next, signer, system, !isNew);
}
}

View File

@ -1,7 +1,3 @@
export interface HasId {
id: string;
}
export * from "./safe-sync";
export * from "./range-sync";
export * from "./json-in-event-sync";

View File

@ -1,14 +1,9 @@
import { SafeSync } from "./safe-sync";
import { HasId } from ".";
import { EventBuilder, EventSigner, NostrEvent, NostrLink, NostrPrefix, SystemInterface } from "..";
import { SafeSync, SafeSyncEvents } from "./safe-sync";
import { EventBuilder, EventSigner, NostrEvent, NostrLink, SystemInterface } from "..";
import debug from "debug";
import EventEmitter from "eventemitter3";
export interface JsonSyncEvents {
change: () => void;
}
export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents> {
export class JsonEventSync<T> extends EventEmitter<SafeSyncEvents> {
#log = debug("JsonEventSync");
#sync: SafeSync;
#json: T;
@ -19,7 +14,7 @@ export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents>
readonly encrypt: boolean,
) {
super();
this.#sync = new SafeSync();
this.#sync = new SafeSync(link);
this.#json = initValue;
this.#sync.on("change", () => this.emit("change"));
@ -31,7 +26,7 @@ export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents>
}
async sync(signer: EventSigner, system: SystemInterface) {
const res = await this.#sync.sync(this.link, system);
const res = await this.#sync.sync(system);
this.#log("Sync result %O", res);
if (res) {
if (this.encrypt) {
@ -71,6 +66,5 @@ export class JsonEventSync<T extends HasId> extends EventEmitter<JsonSyncEvents>
await this.#sync.update(next, signer, system, !isNew);
this.#json = val;
this.#json.id = next.id;
}
}

View File

@ -1,5 +1,14 @@
import EventEmitter from "eventemitter3";
import { EventExt, EventSigner, EventType, NostrEvent, NostrLink, RequestBuilder, SystemInterface } from "..";
import {
EventExt,
EventSigner,
EventType,
NostrEvent,
NostrLink,
NotSignedNostrEvent,
RequestBuilder,
SystemInterface,
} from "..";
import { unixNow } from "@snort/shared";
import debug from "debug";
@ -21,6 +30,10 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
#base: NostrEvent | undefined;
#didSync = false;
constructor(readonly link: NostrLink) {
super();
}
get value() {
return this.#base ? Object.freeze({ ...this.#base }) : undefined;
}
@ -31,14 +44,13 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
/**
* Fetch the latest version
* @param link A link to the kind
*/
async sync(link: NostrLink, system: SystemInterface) {
if (link.kind === undefined || link.author === undefined) {
async sync(system: SystemInterface) {
if (this.link.kind === undefined || this.link.author === undefined) {
throw new Error("Kind must be set");
}
return await this.#sync(link, system);
return await this.#sync(system);
}
/**
@ -57,15 +69,23 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
* Event will be signed again inside
* @param ev
*/
async update(next: NostrEvent, signer: EventSigner, system: SystemInterface, mustExist?: boolean) {
next.id = "";
next.sig = "";
async update(
next: NostrEvent | NotSignedNostrEvent,
signer: EventSigner,
system: SystemInterface,
mustExist?: boolean,
) {
if ("sig" in next) {
next.id = "";
next.sig = "";
}
console.debug(this.#base, next);
const signed = await this.#signEvent(next, signer);
const link = NostrLink.fromEvent(signed);
// always attempt to get a newer version before broadcasting
await this.#sync(link, system);
await this.#sync(system);
this.#checkForUpdate(signed, mustExist ?? true);
system.BroadcastEvent(signed);
@ -73,28 +93,21 @@ export class SafeSync extends EventEmitter<SafeSyncEvents> {
this.emit("change");
}
async #signEvent(next: NostrEvent, signer: EventSigner) {
next.created_at = unixNow();
if (this.#base) {
const prevTag = next.tags.find(a => a[0] === "previous");
if (prevTag) {
prevTag[1] = this.#base.id;
} else {
next.tags.push(["previous", this.#base.id]);
}
}
next.id = EventExt.createId(next);
return await signer.sign(next);
async #signEvent(next: NotSignedNostrEvent, signer: EventSigner) {
const toSign = { ...next, id: "", sig: "" } as NostrEvent;
toSign.created_at = unixNow();
toSign.id = EventExt.createId(toSign);
return await signer.sign(toSign);
}
async #sync(link: NostrLink, system: SystemInterface) {
const rb = new RequestBuilder(`sync:${link.encode()}`);
const f = rb.withFilter().link(link);
async #sync(system: SystemInterface) {
const rb = new RequestBuilder("sync");
const f = rb.withFilter().link(this.link);
if (this.#base) {
f.since(this.#base.created_at);
}
const results = await system.Fetch(rb);
const res = results.find(a => link.matchesEvent(a));
const res = results.find(a => this.link.matchesEvent(a));
this.#log("Got result %O", res);
if (res && res.created_at > (this.#base?.created_at ?? 0)) {
this.#base = res;

View File

@ -0,0 +1,433 @@
import { NostrPrefix } from "./links";
import { NostrLink, ToNostrEventTag } from "./nostr-link";
import { DiffSyncTags, JsonEventSync } from "./sync";
import EventKind from "./event-kind";
import {
EventSigner,
FullRelaySettings,
RelaySettings,
SystemInterface,
UserMetadata,
parseRelayTags,
parseRelaysFromKind,
settingsToRelayTag,
} from ".";
import { dedupe, removeUndefined, sanitizeRelayUrl } from "@snort/shared";
import debug from "debug";
import EventEmitter from "eventemitter3";
export interface UserStateOptions<T> {
appdataId: string;
initAppdata: T;
encryptAppdata: boolean;
}
/**
* Data which can be stored locally to quickly resume the state at startup
*/
export interface UserStateObject<TAppData> {
profile?: UserMetadata;
follows?: Array<string>;
relays?: Array<FullRelaySettings>;
appdata?: TAppData;
}
export const enum UserStateChangeType {
Profile,
Contacts,
Relays,
AppData,
MuteList,
GenericList,
}
export interface UserStateEvents {
change: (t: UserStateChangeType) => void;
}
/**
* Manages a users state, mostly to improve safe syncing
*/
export class UserState<TAppData> extends EventEmitter<UserStateEvents> {
#log = debug("UserState");
#profile: JsonEventSync<UserMetadata | undefined>; // kind 0
#contacts: DiffSyncTags; // kind 3
#relays: DiffSyncTags; // kind 10_003
#appdata?: JsonEventSync<TAppData>; // kind 30_0078
#standardLists: Map<EventKind, DiffSyncTags>; // NIP-51 lists
// init vars
#signer?: EventSigner;
#system?: SystemInterface;
// state object will be used in the getters as a fallback value
#stateObj?: UserStateObject<TAppData>;
#didInit = false;
constructor(
readonly pubkey: string,
options?: Partial<UserStateOptions<TAppData>>,
stateObj?: UserStateObject<TAppData>,
) {
super();
this.#stateObj = stateObj;
this.#standardLists = new Map();
this.#profile = new JsonEventSync<UserMetadata | undefined>(
undefined,
new NostrLink(NostrPrefix.Event, "", EventKind.SetMetadata, pubkey),
false,
);
this.#contacts = new DiffSyncTags(new NostrLink(NostrPrefix.Event, "", EventKind.ContactList, pubkey));
this.#relays = new DiffSyncTags(new NostrLink(NostrPrefix.Event, "", EventKind.Relays, pubkey));
if (options?.appdataId && options.initAppdata) {
const link = new NostrLink(NostrPrefix.Address, options.appdataId, EventKind.AppData, pubkey);
this.#appdata = new JsonEventSync<TAppData>(options.initAppdata, link, options.encryptAppdata ?? false);
this.#appdata.on("change", () => this.emit("change", UserStateChangeType.AppData));
}
// always track mute list
this.#checkIsStandardList(EventKind.MuteList);
this.#profile.on("change", () => this.emit("change", UserStateChangeType.Profile));
this.#contacts.on("change", () => this.emit("change", UserStateChangeType.Contacts));
this.#relays.on("change", () => this.emit("change", UserStateChangeType.Relays));
}
async init(signer: EventSigner, system: SystemInterface) {
if (this.#didInit) {
return;
}
this.#didInit = true;
this.#log("Init start");
this.#signer = signer;
this.#system = system;
const tasks = [
this.#profile.sync(signer, system),
this.#contacts.sync(signer, system),
this.#relays.sync(signer, system),
];
if (this.#appdata) {
tasks.push(this.#appdata.sync(signer, system));
}
for (const list of this.#standardLists.values()) {
tasks.push(list.sync(signer, system));
}
await Promise.all(tasks);
this.#log(
"Init results: profile=%O, contacts=%O, relays=%O, appdata=%O, lists=%O",
this.#profile.json,
this.#contacts.value,
this.#relays.value,
this.#appdata?.json,
[...this.#standardLists.values()].map(a => a.value),
);
// update relay metadata with value from contact list if not found
if (this.#relays.value === undefined && this.#contacts.value?.content !== undefined) {
this.#log("Saving relays to NIP-65 relay list using %O", this.relays);
for (const r of this.relays ?? []) {
await this.addRelay(r.url, r.settings, false);
}
await this.#relays.persist(signer, system);
}
// migrate mutes into blocks
const muteList = this.#standardLists.get(EventKind.MuteList);
if (muteList && muteList.tags.length > 0) {
this.#log("Migrating mutes into blocks mutes=%i, blocks=%i", muteList.tags.length, muteList.encryptedTags.length);
muteList.replace([], false);
muteList.add(muteList!.tags, true);
await muteList.persist(signer, system);
}
}
/**
* Users profile
*/
get profile() {
return this.#profile.json ?? this.#stateObj?.profile;
}
/**
* Users configured relays
*/
get relays() {
if (this.#relays.value) {
return parseRelayTags(this.#relays.tags);
} else if (this.#contacts.value) {
return parseRelaysFromKind(this.#contacts.value);
} else {
return this.#stateObj?.relays;
}
}
/**
* Followed pubkeys
*/
get follows() {
if (this.#contacts.value) {
const pTags = this.#contacts.tags.filter(a => a[0] === "p" && a[1].length === 64).map(a => a[1]) ?? [];
return dedupe(pTags);
} else {
return this.#stateObj?.follows;
}
}
/**
* App specific data
*/
get appdata() {
return this.#appdata?.json ?? this.#stateObj?.appdata;
}
/**
* Get the standard mute list
*/
get muted() {
const list = this.#standardLists.get(EventKind.MuteList);
if (list) {
return NostrLink.fromTags(list.encryptedTags);
}
return [];
}
async follow(link: NostrLink, autoCommit = false) {
this.#checkInit();
if (link.type !== NostrPrefix.Profile && link.type !== NostrPrefix.PublicKey) {
throw new Error("Cannot follow this type of link");
}
const tag = link.toEventTag();
if (tag) {
this.#contacts.add(tag);
if (autoCommit) {
await this.saveContacts();
}
} else {
throw new Error("Invalid link");
}
}
async unfollow(link: NostrLink, autoCommit = false) {
this.#checkInit();
if (link.type !== NostrPrefix.Profile && link.type !== NostrPrefix.PublicKey) {
throw new Error("Cannot follow this type of link");
}
const tag = link.toEventTag();
if (tag) {
this.#contacts.remove(tag);
if (autoCommit) {
await this.saveContacts();
}
} else {
throw new Error("Invalid link");
}
}
async replaceFollows(links: Array<NostrLink>, autoCommit = false) {
this.#checkInit();
if (links.some(link => link.type !== NostrPrefix.Profile && link.type !== NostrPrefix.PublicKey)) {
throw new Error("Cannot follow this type of link");
}
const tags = removeUndefined(links.map(link => link.toEventTag()));
this.#contacts.replace(tags);
if (autoCommit) {
await this.saveContacts();
}
}
/**
* Manually save contact list changes
*
* used with `autocommit = false`
*/
async saveContacts() {
this.#checkInit();
const content = JSON.stringify(this.#relaysObject());
await this.#contacts.persist(this.#signer!, this.#system!, content);
}
async addRelay(addr: string, settings: RelaySettings, autoCommit = false) {
this.#checkInit();
const tag = settingsToRelayTag({
url: addr,
settings,
});
if (tag) {
this.#relays.add(tag);
if (autoCommit) {
await this.saveRelays();
}
} else {
throw new Error("Invalid relay options");
}
}
async removeRelay(addr: string, autoCommit = false) {
this.#checkInit();
const url = sanitizeRelayUrl(addr);
if (url) {
this.#relays.remove(["r", url]);
if (autoCommit) {
await this.saveRelays();
}
} else {
throw new Error("Invalid relay options");
}
}
async updateRelay(addr: string, settings: RelaySettings, autoCommit = false) {
this.#checkInit();
const tag = settingsToRelayTag({
url: addr,
settings,
});
const url = sanitizeRelayUrl(addr);
if (url && tag) {
this.#relays.update(tag);
if (autoCommit) {
await this.saveRelays();
}
} else {
throw new Error("Invalid relay options");
}
}
/**
* Manually save relays
*
* used with `autocommit = false`
*/
async saveRelays() {
this.#checkInit();
await this.#relays.persist(this.#signer!, this.#system!);
}
async setAppData(data: TAppData) {
this.#checkInit();
if (!this.#appdata) {
throw new Error("Not using appdata, please use options when constructing this class");
}
await this.#appdata.updateJson(data, this.#signer!, this.#system!);
}
/**
* Add an item to the list
* @param kind List kind
* @param link Tag to save
* @param autoCommit Save after adding
* @param encrypted Tag is private and should be encrypted in the content
*/
async addToList(
kind: EventKind,
links: ToNostrEventTag | Array<ToNostrEventTag>,
autoCommit = false,
encrypted = false,
) {
this.#checkIsStandardList(kind);
this.#checkInit();
const list = this.#standardLists.get(kind);
const tags = removeUndefined(Array.isArray(links) ? links.map(a => a.toEventTag()) : [links.toEventTag()]);
if (list && tags.length > 0) {
list.add(tags, encrypted);
if (autoCommit) {
await this.saveList(kind);
}
}
}
/**
* Remove an item to the list
* @param kind List kind
* @param link Tag to save
* @param autoCommit Save after adding
* @param encrypted Tag is private and should be encrypted in the content
*/
async removeFromList(
kind: EventKind,
links: ToNostrEventTag | Array<ToNostrEventTag>,
autoCommit = false,
encrypted = false,
) {
this.#checkIsStandardList(kind);
this.#checkInit();
const list = this.#standardLists.get(kind);
const tags = removeUndefined(Array.isArray(links) ? links.map(a => a.toEventTag()) : [links.toEventTag()]);
if (list && tags.length > 0) {
list.remove(tags, encrypted);
if (autoCommit) {
await this.saveList(kind);
}
}
}
/**
* Manuall save list changes
*
* used with `autocommit = false`
*/
async saveList(kind: EventKind, content?: string) {
const list = this.#standardLists.get(kind);
await list?.persist(this.#signer!, this.#system!, content);
}
async mute(link: NostrLink, autoCommit = false) {
await this.addToList(EventKind.MuteList, link, autoCommit, true);
}
async unmute(link: NostrLink, autoCommit = false) {
await this.removeFromList(EventKind.MuteList, link, autoCommit, true);
}
isOnList(kind: EventKind, link: ToNostrEventTag) {
const list = this.#standardLists.get(kind);
const tag = link.toEventTag();
if (list && tag) {
return list.tags.some(a => a[0] === tag[0] && a[1] === tag[1]);
}
return false;
}
getList(kind: EventKind): Array<ToNostrEventTag> {
const list = this.#standardLists.get(kind);
return NostrLink.fromTags(list?.tags ?? []);
}
serialize(): UserStateObject<TAppData> {
return {
profile: this.profile,
relays: this.relays,
follows: this.follows,
appdata: this.appdata,
};
}
#checkIsStandardList(kind: EventKind) {
if (!(kind >= 10_000 && kind < 20_000)) {
throw new Error("Not a standar list");
}
if (!this.#standardLists.has(kind)) {
const list = new DiffSyncTags(new NostrLink(NostrPrefix.Event, "", kind, this.pubkey));
list.on("change", () => this.emit("change", UserStateChangeType.GenericList));
this.#standardLists.set(kind, list);
}
}
#checkInit() {
if (this.#signer === undefined || this.#system === undefined) {
throw new Error("Please call init() first");
}
}
#relaysObject() {
return Object.fromEntries(this.relays?.map(a => [a.url, a.settings]) ?? []) as Record<string, RelaySettings>;
}
}