diff --git a/package.json b/package.json index 8cfc8ea..355c0e8 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,10 @@ "types": "./build/src/main.d.ts", "exports": { ".": { + "types": "./build/src/main.d.ts", "import": "./build/src/main.mjs", "require": "./build/src/main.cjs" - }, - "./types": "./build/src/main.d.ts" + } }, "files": [ "build" diff --git a/src/connect/Subscription.ts b/src/connect/Subscription.ts index 0ce7798..03ac9c6 100644 --- a/src/connect/Subscription.ts +++ b/src/connect/Subscription.ts @@ -1,6 +1,7 @@ import EventEmitter from "events" import type {Event} from 'nostr-tools' import type {Executor} from "./Executor" +import type {Connection} from './Connection' import type {Filter} from '../util/Filters' import {matchFilters} from "../util/Filters" import {hasValidSignature} from "../util/Events" @@ -116,7 +117,7 @@ export class Subscription extends EventEmitter { this.emit("close") this.removeAllListeners() - target.connections.forEach(con => con.off("close", this.closeHandlers.get(con.url))) + target.connections.forEach((con: Connection) => con.off("close", this.closeHandlers.get(con.url))) target.cleanup() } } diff --git a/src/main.ts b/src/main.ts index 4982b9e..7ea2dbd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,7 @@ export * from "./connect/target/Multi" export * from "./connect/target/Plex" export * from "./connect/target/Relay" export * from "./connect/target/Relays" +export * from "./util/Address" export * from "./util/Deferred" export * from "./util/Emitter" export * from "./util/Events" diff --git a/src/util/Address.ts b/src/util/Address.ts index f35a3f4..9fec50c 100644 --- a/src/util/Address.ts +++ b/src/util/Address.ts @@ -7,7 +7,7 @@ export const isGroupAddress = (a: string) => a.startsWith(`${GROUP_DEFINITION}:` export const isCommunityAddress = (a: string) => a.startsWith(`${COMMUNITY_DEFINITION}:`) -export const isCommunityOrGroupAddress = (a: string) => isCommunityAddress(a) || isGroupAddress(a) +export const isContextAddress = (a: string) => isCommunityAddress(a) || isGroupAddress(a) export class Address { readonly kind: number @@ -23,24 +23,14 @@ export class Address { } static fromEvent = (e: UnsignedEvent, relays: string[] = []) => - new Address(e.kind, e.pubkey, Tags.fromEvent(e).whereKey("d").values().first(), relays) + new Address(e.kind, e.pubkey, Tags.fromEvent(e).get("d")?.value() || "", relays) - static fromTagValue = (a: string, relays: string[] = []) => { + static fromRaw = (a: string, relays: string[] = []) => { const [kind, pubkey, identifier] = a.split(":") return new Address(kind, pubkey, identifier, relays) } - static fromTag = (tag: string[], relays: string[] = []) => { - const [a, hint] = tag.slice(1) - - if (hint) { - relays = relays.concat(hint) - } - - return this.fromTagValue(a, relays) - } - static fromNaddr = (naddr: string) => { let type let data = {} as any @@ -60,10 +50,10 @@ export class Address { return new Address(data.kind, data.pubkey, data.identifier, data.relays) } - asTagValue = () => [this.kind, this.pubkey, this.identifier].join(":") + asRaw = () => [this.kind, this.pubkey, this.identifier].join(":") asTag = (mark?: string) => { - const tag = ["a", this.asTagValue(), this.relays[0] || ""] + const tag = ["a", this.asRaw(), this.relays[0] || ""] if (mark) { tag.push(mark) diff --git a/src/util/Events.ts b/src/util/Events.ts index 5a1117a..8395006 100644 --- a/src/util/Events.ts +++ b/src/util/Events.ts @@ -1,4 +1,4 @@ -import type {Event, EventTemplate} from 'nostr-tools' +import type {Event, EventTemplate, UnsignedEvent} from 'nostr-tools' import {verifyEvent, getEventHash} from 'nostr-tools' import {cached} from "./LRUCache" import {now} from './Tools' @@ -16,6 +16,15 @@ export type CreateEventOpts = { export const createEvent = (kind: number, {content = "", tags = [], created_at = now()}: CreateEventOpts) => ({kind, content, tags, created_at}) +export const asEventTemplate = ({kind, tags, content, created_at}: EventTemplate): EventTemplate => + ({kind, tags, content, created_at}) + +export const asUnsignedEvent = ({kind, tags, content, created_at, pubkey}: UnsignedEvent): UnsignedEvent => + ({kind, tags, content, created_at, pubkey}) + +export const asRumor = ({kind, tags, content, created_at, pubkey, id}: Rumor): Rumor => + ({kind, tags, content, created_at, pubkey, id}) + export const hasValidSignature = cached({ maxSize: 10000, getKey: ([e]: [Event]) => { @@ -34,15 +43,12 @@ export const hasValidSignature = cached({ }, }) -export const getAddress = (e: Rumor) => Address.fromEvent(e).asTagValue() +export const getAddress = (e: UnsignedEvent) => Address.fromEvent(e).asRaw() export const getIdOrAddress = (e: Rumor) => isReplaceable(e) ? getAddress(e) : e.id export const getIdAndAddress = (e: Rumor) => isReplaceable(e) ? [e.id, getAddress(e)] : [e.id] -export const getIdOrAddressTag = (e: Rumor, hint: string) => - isReplaceable(e) ? ["a", getAddress(e), hint] : ["e", e.id, hint] - export const isEphemeral = (e: EventTemplate) => isEphemeralKind(e.kind) export const isReplaceable = (e: EventTemplate) => isReplaceableKind(e.kind) diff --git a/src/util/Fluent.ts b/src/util/Fluent.ts index 1aa5026..4d1e747 100644 --- a/src/util/Fluent.ts +++ b/src/util/Fluent.ts @@ -3,6 +3,10 @@ import {last} from './Tools' export class Fluent { constructor(readonly xs: T[]) {} + static create() { + return this.from([]) + } + static from(xs: Iterable) { return new Fluent(Array.from(xs)) } @@ -23,6 +27,8 @@ export class Fluent { exists = () => this.xs.length > 0 + has = (v: T) => this.xs.includes(v) + every = (f: (t: T) => boolean) => this.xs.every(f) some = (f: (t: T) => boolean) => this.xs.some(f) @@ -43,11 +49,17 @@ export class Fluent { map = (f: (t: T) => T) => this.clone(this.xs.map(f)) - mapTo = (f: (t: T) => U) => new Fluent(this.xs.map(f)) + mapTo = (f: (t: T) => U) => Fluent.from(this.xs.map(f)) - flatMap = (f: (t: T) => U[]) => new Fluent(this.xs.flatMap(f)) + flatMap = (f: (t: T) => U[]) => Fluent.from(this.xs.flatMap(f)) + + forEach = (f: (t: T, i: number) => void) => this.xs.forEach(f) + + set = (i: number, x: T) => this.clone([...this.xs.slice(0, i), x, ...this.xs.slice(i + 1)]) concat = (xs: T[]) => this.clone(this.xs.concat(xs)) append = (x: T) => this.concat([x]) + + prepend = (x: T) => this.clone([x].concat(this.xs)) } diff --git a/src/util/Router.ts b/src/util/Router.ts index 2d451b2..de2978f 100644 --- a/src/util/Router.ts +++ b/src/util/Router.ts @@ -1,14 +1,26 @@ -import type {Event} from 'nostr-tools' -import {Tags} from './Tags' -import {nth, first} from '../util/Tools' +import type {EventTemplate, UnsignedEvent} from 'nostr-tools' +import type {Rumor} from './Events' +import {nip19} from 'nostr-tools' +import {getAddress, isReplaceable} from './Events' +import {Tag, Tags} from './Tags' +import {first, uniq, shuffle} from './Tools' +import {isGroupAddress, isCommunityAddress} from './Address' + +export enum RelayMode { + Inbox = "inbox", + Outbox = "outbox", +} export type RouterOptions = { getUserPubkey: () => string | null - getGroupRelayTags: (address: string) => string[][] - getCommunityRelayTags: (address: string) => string[][] - getPubkeyRelayTags: (pubkey: string) => string[][] - getFallbackRelayTags: () => string[][] + getGroupRelays: (address: string) => string[] + getCommunityRelays: (address: string) => string[] + getPubkeyInboxRelays: (pubkey: string) => string[] + getPubkeyOutboxRelays: (pubkey: string) => string[] + getFallbackInboxRelays: () => string[] + getFallbackOutboxRelays: () => string[] getRelayQuality?: (url: string) => number + getDefaultLimit: () => number } // - Fetch from and publish to non-shareable relays, but don't use them for hints @@ -19,43 +31,36 @@ export class Router { // Utilities derived from options - getGroupRelayUrls = (address: string) => - this.options.getGroupRelayTags(address).map(nth(1)) + getAllPubkeyRelays = (pubkey: string) => + [ + ...this.options.getPubkeyInboxRelays(pubkey), + ...this.options.getPubkeyOutboxRelays(pubkey), + ] - getCommunityRelayUrls = (address: string) => - this.options.getCommunityRelayTags(address).map(nth(1)) - - getPubkeyRelayTags = (pubkey: string, mode?: string) => { - const tags = this.options.getPubkeyRelayTags(pubkey) - - return mode ? Tags.from(tags).whereMark(mode).valueOf() : tags - } - - getPubkeyRelayUrls = (pubkey: string, mode?: string) => - this.getPubkeyRelayTags(pubkey, mode).map(nth(1)) - - getUserRelayTags = (mode?: string) => { + getUserInboxRelays = () => { const pubkey = this.options.getUserPubkey() - return pubkey ? this.getPubkeyRelayTags(pubkey, mode) : [] + return pubkey ? this.options.getPubkeyInboxRelays(pubkey) : [] } - getUserRelayUrls = (mode?: string) => { + getUserOutboxRelays = () => { const pubkey = this.options.getUserPubkey() - return pubkey ? this.getPubkeyRelayUrls(pubkey, mode) : [] + return pubkey ? this.options.getPubkeyOutboxRelays(pubkey) : [] } - getEventGroupOrCommunityRelayUrlGroups = (event: Event, otherGroups: string[][]) => { - const groupAddresses = Tags.fromEvent(event).groups().valueOf() + getAllUserRelays = () => { + const pubkey = this.options.getUserPubkey() - if (groupAddresses.length > 0) { - return groupAddresses.map(this.getGroupRelayUrls) - } + return pubkey ? this.getAllPubkeyRelays(pubkey) : [] + } + + getEventContextRelayGroups = (event: EventTemplate) => { + const addresses = Tags.fromEvent(event).context().values().valueOf() return [ - ...Tags.fromEvent(event).communities().valueOf().map(this.getCommunityRelayUrls), - ...otherGroups, + ...addresses.filter(isCommunityAddress).map(this.options.getCommunityRelays), + ...addresses.filter(isGroupAddress).map(this.options.getGroupRelays), ] } @@ -64,19 +69,16 @@ export class Router { getGroupScores = (groups: string[][]) => { const scores: RouteScenarioScores = {} - // TODO: see if weighting earlier groups slightly heavier improves things - for (const urls of groups) { - urls.forEach((url, i) => { - const score = 1 / (i + 1) / urls.length - + groups.forEach((urls, i) => { + for (const url of shuffle(uniq(urls))) { if (!scores[url]) { scores[url] = {score: 0, count: 0} } - scores[url].score += score + scores[url].score += 1 / (i + 1) scores[url].count += 1 - }) - } + } + }) // Use log-sum-exp to get a a weighted sum for (const [url, score] of Object.entries(scores)) { @@ -91,95 +93,210 @@ export class Router { } urlsFromScores = (limit: number, scores: RouteScenarioScores) => - Object.entries(scores).sort((a, b) => a[1].score > b[1].score ? 1 : -1).map(pair => pair[0] as string).slice(0, limit) + Object.entries(scores).sort((a, b) => a[1].score > b[1].score ? -1 : 1).map(pair => pair[0] as string).slice(0, limit) - groupsToUrls = (limit: number, groups: string[][]) => - this.urlsFromScores(limit, this.getGroupScores(groups)) + groupsToUrls = (limit: number, groups: string[][]) => this.urlsFromScores(limit, this.getGroupScores(groups)) + + scenario = (options: RouterScenarioOptions) => new RouterScenario(this, options) + + merge = ({fallbackPolicy, scenarios}: {fallbackPolicy: FallbackPolicy, scenarios: RouterScenario[]}) => + this.scenario({fallbackPolicy, getGroups: () => scenarios.map(s => s.getRawUrls())}) // Routing scenarios - FetchAllDirectMessage = () => new RouterScenario(this, { - fallbackPolicy: useMinimalFallbacks("read"), - getGroups: () => [this.getUserRelayUrls()], + Broadcast = () => this.scenario({ + fallbackPolicy: useMinimalFallbacks(RelayMode.Outbox), + getGroups: () => [this.getAllUserRelays()], }) - FetchDirectMessages = (pubkey: string) => new RouterScenario(this, { - fallbackPolicy: useMinimalFallbacks("read"), - getGroups: () => [this.getUserRelayUrls(), this.getPubkeyRelayUrls(pubkey)], + Aggregate = () => this.scenario({ + fallbackPolicy: useMinimalFallbacks(RelayMode.Inbox), + getGroups: () => [this.getAllUserRelays()], }) - PublishDirectMessage = (pubkey: string) => new RouterScenario(this, { - fallbackPolicy: useMinimalFallbacks("write"), - getGroups: () => [this.getUserRelayUrls("write"), this.getPubkeyRelayUrls(pubkey, "read")], + NoteToSelf = () => this.scenario({ + fallbackPolicy: useMaximalFallbacks(RelayMode.Inbox), + getGroups: () => [this.getUserInboxRelays()], }) - FetchPubkeyEvents = (pubkey: string) => new RouterScenario(this, { - fallbackPolicy: useMaximalFallbacks("read"), - getGroups: () => [this.getPubkeyRelayUrls(pubkey, "write")], + FetchAllMessages = () => this.scenario({ + fallbackPolicy: useMinimalFallbacks(RelayMode.Inbox), + getGroups: () => [this.getAllUserRelays()], }) - FetchEvent = (event: Event) => new RouterScenario(this, { - fallbackPolicy: useMaximalFallbacks("read"), - getGroups: () => - this.getEventGroupOrCommunityRelayUrlGroups(event, [ - this.getPubkeyRelayUrls(event.pubkey, "write"), - ]), + FetchMessages = (pubkeys: string[]) => this.scenario({ + fallbackPolicy: useMinimalFallbacks(RelayMode.Inbox), + getGroups: () => [ + this.getAllUserRelays(), + ...pubkeys.map(this.getAllPubkeyRelays) + ], }) - FetchEventChildren = (event: Event) => new RouterScenario(this, { - fallbackPolicy: useMaximalFallbacks("read"), - getGroups: () => - this.getEventGroupOrCommunityRelayUrlGroups(event, [ - this.getPubkeyRelayUrls(event.pubkey, "read"), - ]), + PublishMessage = (pubkeys: string[]) => this.scenario({ + fallbackPolicy: useMinimalFallbacks(RelayMode.Outbox), + getGroups: () => [ + this.getUserOutboxRelays(), + ...pubkeys.map(this.options.getPubkeyInboxRelays) + ], }) - FetchEventParent = (event: Event) => new RouterScenario(this, { - fallbackPolicy: useMaximalFallbacks("read"), - getGroups: () => - this.getEventGroupOrCommunityRelayUrlGroups(event, [ - Tags.fromEvent(event).replies().relays().valueOf(), - this.getPubkeyRelayUrls(event.pubkey, "read"), - ]), + FetchEvent = (event: UnsignedEvent) => this.scenario({ + fallbackPolicy: useMaximalFallbacks(RelayMode.Inbox), + getGroups: () => [ + this.options.getPubkeyOutboxRelays(event.pubkey), + ...this.getEventContextRelayGroups(event), + ], }) - FetchEventRoot = (event: Event) => new RouterScenario(this, { - fallbackPolicy: useMaximalFallbacks("read"), - getGroups: () => - this.getEventGroupOrCommunityRelayUrlGroups(event, [ - Tags.fromEvent(event).roots().relays().valueOf(), - this.getPubkeyRelayUrls(event.pubkey, "read"), - ]), + FetchEventChildren = (event: UnsignedEvent) => this.scenario({ + fallbackPolicy: useMaximalFallbacks(RelayMode.Inbox), + getGroups: () => [ + this.options.getPubkeyInboxRelays(event.pubkey), + ...this.getEventContextRelayGroups(event), + ], }) - PublishEvent = (event: Event) => new RouterScenario(this, { - fallbackPolicy: useMinimalFallbacks("write"), - getGroups: () => - this.getEventGroupOrCommunityRelayUrlGroups(event, [ - this.getPubkeyRelayUrls(event.pubkey, "write"), - ...Tags.fromEvent(event).whereKey("p").values().valueOf().map((pk: string) => this.getPubkeyRelayUrls(pk, "read")), - ]), + FetchEventParent = (event: UnsignedEvent) => this.scenario({ + fallbackPolicy: useMaximalFallbacks(RelayMode.Inbox), + getGroups: () => [ + Tags.fromEvent(event).replies().relays().valueOf(), + this.options.getPubkeyInboxRelays(event.pubkey), + ...this.getEventContextRelayGroups(event), + ], }) - FetchFromGroup = (address: string) => new RouterScenario(this, { + FetchEventRoot = (event: UnsignedEvent) => this.scenario({ + fallbackPolicy: useMaximalFallbacks(RelayMode.Inbox), + getGroups: () => [ + Tags.fromEvent(event).roots().relays().valueOf(), + this.options.getPubkeyInboxRelays(event.pubkey), + ...this.getEventContextRelayGroups(event), + ], + }) + + PublishEvent = (event: UnsignedEvent) => this.scenario({ + fallbackPolicy: useMinimalFallbacks(RelayMode.Outbox), + getGroups: () => { + const tags = Tags.fromEvent(event) + const mentions = tags.values("p").valueOf() + const addresses = tags.context().values().valueOf() + const groupAddresses = addresses.filter(isGroupAddress) + const communityAddresses = addresses.filter(isCommunityAddress) + + // If we're publishing only to private groups, only publish to those groups' relays. + // Otherwise, publish to all relays, because it's essentially public. + if (groupAddresses.length > 0 && communityAddresses.length === 0) { + return groupAddresses.map(this.options.getGroupRelays) + } + + return [ + this.options.getPubkeyOutboxRelays(event.pubkey), + ...groupAddresses.map(this.options.getGroupRelays), + ...communityAddresses.map(this.options.getCommunityRelays), + ...mentions.map((pk: string) => this.options.getPubkeyInboxRelays(pk)), + ] + }, + }) + + FetchFromHints = (...groups: string[][]) => this.scenario({ + fallbackPolicy: useMaximalFallbacks(RelayMode.Inbox), + getGroups: () => [...groups, this.getAllUserRelays()], + }) + + FetchFromPubkey = (pubkey: string) => this.scenario({ + fallbackPolicy: useMaximalFallbacks(RelayMode.Outbox), + getGroups: () => [this.options.getPubkeyOutboxRelays(pubkey)], + }) + + FetchFromPubkeys = (pubkeys: string[]) => this.scenario({ + fallbackPolicy: useMaximalFallbacks(RelayMode.Outbox), + getGroups: () => pubkeys.map(this.options.getPubkeyOutboxRelays), + }) + + FetchFromGroup = (address: string) => this.scenario({ fallbackPolicy: useNoFallbacks(), - getGroups: () => [this.getGroupRelayUrls(address)], + getGroups: () => [this.options.getGroupRelays(address)], }) - PublishToGroup = (address: string) => new RouterScenario(this, { + PublishToGroup = (address: string) => this.scenario({ fallbackPolicy: useNoFallbacks(), - getGroups: () => [this.getGroupRelayUrls(address)], + getGroups: () => [this.options.getGroupRelays(address)], }) - FetchFromCommunity = (address: string) => new RouterScenario(this, { - fallbackPolicy: useMaximalFallbacks("read"), - getGroups: () => [this.getCommunityRelayUrls(address)], + FetchFromCommunity = (address: string) => this.scenario({ + fallbackPolicy: useMaximalFallbacks(RelayMode.Inbox), + getGroups: () => [this.options.getCommunityRelays(address)], }) - PublishToCommunity = (address: string) => new RouterScenario(this, { - fallbackPolicy: useMaximalFallbacks("write"), - getGroups: () => [this.getCommunityRelayUrls(address)], + PublishToCommunity = (address: string) => this.scenario({ + fallbackPolicy: useMaximalFallbacks(RelayMode.Outbox), + getGroups: () => [this.options.getCommunityRelays(address)], }) + + FetchFromContext = (address: string) => { + if (isGroupAddress(address)) { + return this.FetchFromGroup(address) + } + + if (isCommunityAddress(address)) { + return this.FetchFromCommunity(address) + } + + throw new Error(`Unknown context ${address}`) + } + + FetchFromContexts = (addresses: string[]) => + this.merge({ + fallbackPolicy: useMinimalFallbacks(RelayMode.Outbox), + scenarios: addresses.map(this.FetchFromContext), + }) + + PublishToContext = (address: string) => { + if (isGroupAddress(address)) { + return this.PublishToGroup(address) + } + + if (isCommunityAddress(address)) { + return this.PublishToCommunity(address) + } + + throw new Error(`Unknown context ${address}`) + } + + PublishToContexts = (addresses: string[]) => + this.merge({ + fallbackPolicy: useMinimalFallbacks(RelayMode.Outbox), + scenarios: addresses.map(this.PublishToContext), + }) + + // Higher level utils that use hints + + tagPubkey = (pubkey: string) => + Tag.from(["p", pubkey, this.FetchFromPubkey(pubkey).getUrl()]) + + tagEventId = (event: Rumor, ...extra: string[]) => + Tag.from(["e", event.id, this.FetchEvent(event).getUrl(), ...extra]) + + tagEventAddress = (event: UnsignedEvent, ...extra: string[]) => + Tag.from(["a", getAddress(event), this.FetchEvent(event).getUrl(), ...extra]) + + tagEvent = (event: Rumor, ...extra: string[]) => { + const tags = [this.tagEventId(event, ...extra)] + + if (isReplaceable(event)) { + tags.push(this.tagEventAddress(event, ...extra)) + } + + return new Tags(tags) + } + + getNaddr = (event: UnsignedEvent) => + nip19.naddrEncode({ + kind: event.kind, + pubkey: event.pubkey, + identifier: Tags.fromEvent(event).get("d")?.value() || "", + relays: this.FetchEvent(event).getUrls(3), + }) } // Router Scenario @@ -194,24 +311,41 @@ export type RouteScenarioScores = Record export class RouterScenario { constructor(readonly router: Router, readonly options: RouterScenarioOptions) {} - addFallbackUrls = (limit: number, urls: string[]) => { - if (urls.length < limit) { - const {mode, getLimit} = this.options.fallbackPolicy - const fallbackRelayTags = this.router.options.getFallbackRelayTags() - const fallbackUrls = Tags.from(fallbackRelayTags).whereMark(mode).values().valueOf() - const fallbackLimit = getLimit(limit, urls) + getFallbackRelays = () => { + switch (this.options.fallbackPolicy.mode) { + case RelayMode.Inbox: + return this.router.options.getFallbackInboxRelays() + case RelayMode.Outbox: + return this.router.options.getFallbackOutboxRelays() + default: + throw new Error(`Invalid relay mode ${this.options.fallbackPolicy.mode}`) + } + } - return [...urls, ...fallbackUrls.slice(0, fallbackLimit)] + addFallbacks = (limit: number, urls: string[]) => { + if (urls.length < limit) { + const fallbackRelays = this.getFallbackRelays() + const fallbackLimit = this.options.fallbackPolicy.getLimit(limit, urls) + + return [...urls, ...fallbackRelays.slice(0, fallbackLimit)] } return urls } - getUrls = (limit: number, extra: string[] = []) => { + getRawUrls = (limit?: number, extra: string[] = []) => { + const maxRelays = limit || this.router.options.getDefaultLimit() const urlGroups = this.options.getGroups().concat([extra]) - const urls = this.router.groupsToUrls(limit, urlGroups) - return this.addFallbackUrls(limit, urls) + return this.router.groupsToUrls(maxRelays, urlGroups) + } + + getUrls = (limit?: number, extra: string[] = []) => { + const maxRelays = limit || this.router.options.getDefaultLimit() + const urlGroups = [extra].concat(this.options.getGroups()) + const urls = this.router.groupsToUrls(maxRelays, urlGroups) + + return this.addFallbacks(maxRelays, urls) } getUrl = () => first(this.getUrls(1)) @@ -219,12 +353,12 @@ export class RouterScenario { // Fallback Policy -class FallbackPolicy { +export class FallbackPolicy { constructor(readonly mode: string, readonly getLimit: (limit: number, urls: string[]) => number) {} } -const useNoFallbacks = () => new FallbackPolicy("read", (limit: number, urls: string[]) => 0) +export const useNoFallbacks = () => new FallbackPolicy(RelayMode.Inbox, (limit: number, urls: string[]) => 0) -const useMinimalFallbacks = (mode: string) => new FallbackPolicy(mode, (limit: number, urls: string[]) => urls.length === 0 ? 1 : 0) +export const useMinimalFallbacks = (mode: string) => new FallbackPolicy(mode, (limit: number, urls: string[]) => urls.length === 0 ? 1 : 0) -const useMaximalFallbacks = (mode: string) => new FallbackPolicy(mode, (limit: number, urls: string[]) => Math.max(0, limit - urls.length)) +export const useMaximalFallbacks = (mode: string) => new FallbackPolicy(mode, (limit: number, urls: string[]) => Math.max(0, limit - urls.length)) diff --git a/src/util/Tags.ts b/src/util/Tags.ts index 5203b77..5af839f 100644 --- a/src/util/Tags.ts +++ b/src/util/Tags.ts @@ -1,15 +1,31 @@ -import type {EventTemplate} from 'nostr-tools' +import {EventTemplate} from 'nostr-tools' +import {nip19} from 'nostr-tools' import {Fluent} from './Fluent' -import type {OmitAllStatics} from './Tools' +import type {OmitStatics} from './Tools' import {last} from './Tools' import {isShareableRelayUrl} from './Relays' -import {isCommunityAddress, isGroupAddress, isCommunityOrGroupAddress} from './Address' +import {isCommunityAddress, isGroupAddress, isContextAddress} from './Address' -export class Tag extends (Fluent as OmitAllStatics>) { +export class Tag extends (Fluent as OmitStatics, 'from'>) { static from(xs: Iterable) { return new Tag(Array.from(xs)) } + static fromNaddr(naddr: string) { + const {type, data} = nip19.decode(naddr) as { + type: "naddr" + data: nip19.AddressPointer + } + + if (type !== "naddr") { + throw new Error(`Invalid naddr ${naddr}`) + } + + const {kind, pubkey, identifier, relays = []} = data + + return Tag.from(["a", [kind, pubkey, identifier].join(':'), ...relays.slice(0, 1)]) + } + valueOf = () => this.xs key = () => this.xs[0] @@ -19,19 +35,25 @@ export class Tag extends (Fluent as OmitAllStatics mark = () => last(this.xs.slice(2)) entry = () => this.xs.slice(0, 2) + + setKey = (k: string) => this.set(0, k) + + setValue = (v: string) => this.set(1, v) + + setMark = (m: string) => this.xs.length > 2 ? this.set(this.xs.length - 2, m) : this.append(m) } -export class Tags extends (Fluent as OmitAllStatics>) { +export class Tags extends (Fluent as OmitStatics, 'from'>) { static from(p: Iterable) { return new Tags(Array.from(p).map(Tag.from)) } static fromEvent(event: EventTemplate) { - return Tags.from(event.tags) + return Tags.from(event.tags || []) } - static fromEvents(events: Iterable) { - return Tags.from(Array.from(events).flatMap((e: EventTemplate) => e.tags)) + static fromEvents(events: EventTemplate[]) { + return Tags.from(events.flatMap(e => e.tags || [])) } // @ts-ignore @@ -43,9 +65,17 @@ export class Tags extends (Fluent as OmitAllStatics>) { whereMark = (mark: string) => this.filter(t => t.mark() === mark) + removeKey = (key: string) => this.reject(t => t.key() === key) + + removeValue = (value: string) => this.reject(t => t.value() === value) + + removeMark = (mark: string) => this.reject(t => t.mark() === mark) + + get = (key: string) => this.whereKey(key).first() + keys = () => this.mapTo(t => t.key()) - values = () => this.mapTo(t => t.value()) + values = (key?: string) => (key ? this.whereKey(key) : this).mapTo(t => t.value()) marks = () => this.mapTo(t => t.mark()) @@ -55,44 +85,124 @@ export class Tags extends (Fluent as OmitAllStatics>) { topics = () => this.whereKey("t").values().map((t: string) => t.replace(/^#/, "")) - getAncestorsLegacy(this: Tags) { - // Legacy only supports e tags. Normalize their length to 3 - const eTags = - this - .whereKey("e") - .map((t: Tag) => t.concat([""]).slice(0, 3)) + ancestors = () => { + const tags = this.filter(t => ["a", "e"].includes(t.key()) && !isContextAddress(t.value())) + const roots: string[][] = [] + const replies: string[][] = [] + const mentions: string[][] = [] + + tags + .forEach((t: Tag, i: number) => { + if (t.mark() === 'root') { + roots.push(t.valueOf()) + } else if (t.mark() === 'reply') { + replies.push(t.valueOf()) + } else if (t.mark() === 'mention') { + mentions.push(t.valueOf()) + } else if (i === 0) { + roots.push(t.valueOf()) + } else if (i === tags.count() - 1) { + replies.push(t.valueOf()) + } else { + mentions.push(t.valueOf()) + } + }) return { - roots: eTags.slice(0, 1), - replies: eTags.slice(-1), - mentions: eTags.slice(1, -1), + roots: Tags.from(roots), + replies: Tags.from(replies), + mentions: Tags.from(mentions), } } - getAncestors = (key?: string) => { - // If we have a mark, we're not using the legacy format - if (!this.some((t: Tag) => t.count() === 4 && ["reply", "root", "mention"].includes(t.mark()))) { - return this.getAncestorsLegacy() - } + roots = () => this.ancestors().roots - const eTags = this.whereKey("e") - const aTags = this.whereKey("a").reject((t: Tag) => isCommunityOrGroupAddress(t.value())) - const allTags = eTags.concat(aTags.xs) + replies = () => this.ancestors().replies - return { - roots: allTags.whereMark('root').map((t: Tag) => t.take(3)), - replies: allTags.whereMark('reply').map((t: Tag) => t.take(3)), - mentions: allTags.whereMark('mention').map((t: Tag) => t.take(3)), - } + mentions = () => this.ancestors().mentions + + root = () => { + const roots = this.roots() + + return roots.get("e") || roots.get("a") } - roots = (key?: string) => this.getAncestors(key).roots + reply = () => { + const replies = this.replies() - replies = (key?: string) => this.getAncestors(key).replies + return replies.get("e") || replies.get("a") + } - groups = () => this.whereKey("a").values().filter(isGroupAddress) + parents = () => { + const {roots, replies} = this.ancestors() - communities = () => this.whereKey("a").values().filter(isCommunityAddress) + return replies.exists() ? replies: roots + } - communitiesAndGroups = () => this.whereKey("a").values().filter(isCommunityOrGroupAddress) + parent = () => { + const parents = this.parents() + + return parents.get("e") || parents.get("a") + } + + groups = () => this.whereKey("a").filter(t => isGroupAddress(t.value())) + + communities = () => this.whereKey("a").filter(t => isCommunityAddress(t.value())) + + context = () => this.whereKey("a").filter(t => isContextAddress(t.value())) + + asObject = () => { + const result: Record = {} + + for (const t of this.xs) { + result[t.key()] = t.value() + } + + return result + } + + imeta = (url: string) => { + for (const tag of this.whereKey("imeta").xs) { + const tags = Tags.from(tag.drop(1).valueOf().map((m: string) => m.split(" "))) + + if (tags.get("url")?.value() === url) { + return tags + } + } + + return null + } + + // Generic setters + + addTag = (...args: string[]) => this.append(Tag.from(args)) + + setTag = (k: string, ...args: string[]) => this.removeKey(k).addTag(k, ...args) + + // Context + + addContext = (addresses: string[]) => this.concat(addresses.map(a => Tag.from(["a", a]))) + + removeContext = () => this.reject(t => t.key() === "a" && isContextAddress(t.value())) + + setContext = (addresses: string[]) => this.removeContext().addContext(addresses) + + // Images + + addImages = (imeta: Tags[]) => + this.concat(imeta.map(tags => Tag.from(["image", tags.get("url").value()]))) + + removeImages = () => this.removeKey('image') + + setImages = (imeta: Tags[]) => this.removeImages().addImages(imeta) + + // IMeta + + addIMeta = (imeta: Tags[]) => + this.concat(imeta.map(tags => Tag.from(["imeta", ...tags.valueOf().map(xs => xs.join(" "))]))) + + removeIMeta = () => this.removeKey('imeta') + + setIMeta = (imeta: Tags[]) => this.removeIMeta().addIMeta(imeta) } + diff --git a/src/util/Tools.ts b/src/util/Tools.ts index d32852e..b5fe946 100644 --- a/src/util/Tools.ts +++ b/src/util/Tools.ts @@ -14,12 +14,20 @@ export const flatten = (xs: T[]) => xs.flatMap(identity) export const uniq = (xs: T[]) => Array.from(new Set(xs)) +export const shuffle = (xs: T[]): T[] => xs.sort(() => Math.random() > 0.5 ? 1 : -1) + export const isIterable = (x: any) => Symbol.iterator in Object(x) export const toIterable = (x: any) => isIterable(x) ? x : [x] export const stripProtocol = (url: string) => url.replace(/.*:\/\//, "") +// https://github.com/microsoft/TypeScript/issues/4628#issuecomment-1147905253 +export type OmitStatics = + T extends {new(...args: infer A): infer R} ? + {new(...args: A): R}&Omit : + Omit; + // https://github.com/microsoft/TypeScript/issues/4628#issuecomment-1147905253 export type OmitAllStatics = T extends {new(...args: infer A): infer R, prototype: infer P} ?