feat: UserState
This commit is contained in:
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
73
packages/system/src/impl/nip10.ts
Normal file
73
packages/system/src/impl/nip10.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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,
|
@ -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";
|
||||
|
@ -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 {
|
||||
|
@ -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]);
|
||||
}
|
||||
|
@ -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";
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -18,4 +18,5 @@ export interface RelayInfo {
|
||||
language_tags?: Array<string>;
|
||||
tags?: Array<string>;
|
||||
posting_policy?: string;
|
||||
negentropy?: string;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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>;
|
||||
|
@ -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>;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,3 @@
|
||||
export interface HasId {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export * from "./safe-sync";
|
||||
export * from "./range-sync";
|
||||
export * from "./json-in-event-sync";
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
433
packages/system/src/user-state.ts
Normal file
433
packages/system/src/user-state.ts
Normal 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>;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user