Get routing working
This commit is contained in:
+2
-2
@@ -15,10 +15,10 @@
|
|||||||
"types": "./build/src/main.d.ts",
|
"types": "./build/src/main.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
"types": "./build/src/main.d.ts",
|
||||||
"import": "./build/src/main.mjs",
|
"import": "./build/src/main.mjs",
|
||||||
"require": "./build/src/main.cjs"
|
"require": "./build/src/main.cjs"
|
||||||
},
|
}
|
||||||
"./types": "./build/src/main.d.ts"
|
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"build"
|
"build"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import EventEmitter from "events"
|
import EventEmitter from "events"
|
||||||
import type {Event} from 'nostr-tools'
|
import type {Event} from 'nostr-tools'
|
||||||
import type {Executor} from "./Executor"
|
import type {Executor} from "./Executor"
|
||||||
|
import type {Connection} from './Connection'
|
||||||
import type {Filter} from '../util/Filters'
|
import type {Filter} from '../util/Filters'
|
||||||
import {matchFilters} from "../util/Filters"
|
import {matchFilters} from "../util/Filters"
|
||||||
import {hasValidSignature} from "../util/Events"
|
import {hasValidSignature} from "../util/Events"
|
||||||
@@ -116,7 +117,7 @@ export class Subscription extends EventEmitter {
|
|||||||
this.emit("close")
|
this.emit("close")
|
||||||
this.removeAllListeners()
|
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()
|
target.cleanup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export * from "./connect/target/Multi"
|
|||||||
export * from "./connect/target/Plex"
|
export * from "./connect/target/Plex"
|
||||||
export * from "./connect/target/Relay"
|
export * from "./connect/target/Relay"
|
||||||
export * from "./connect/target/Relays"
|
export * from "./connect/target/Relays"
|
||||||
|
export * from "./util/Address"
|
||||||
export * from "./util/Deferred"
|
export * from "./util/Deferred"
|
||||||
export * from "./util/Emitter"
|
export * from "./util/Emitter"
|
||||||
export * from "./util/Events"
|
export * from "./util/Events"
|
||||||
|
|||||||
+5
-15
@@ -7,7 +7,7 @@ export const isGroupAddress = (a: string) => a.startsWith(`${GROUP_DEFINITION}:`
|
|||||||
|
|
||||||
export const isCommunityAddress = (a: string) => a.startsWith(`${COMMUNITY_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 {
|
export class Address {
|
||||||
readonly kind: number
|
readonly kind: number
|
||||||
@@ -23,24 +23,14 @@ export class Address {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static fromEvent = (e: UnsignedEvent, relays: string[] = []) =>
|
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(":")
|
const [kind, pubkey, identifier] = a.split(":")
|
||||||
|
|
||||||
return new Address(kind, pubkey, identifier, relays)
|
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) => {
|
static fromNaddr = (naddr: string) => {
|
||||||
let type
|
let type
|
||||||
let data = {} as any
|
let data = {} as any
|
||||||
@@ -60,10 +50,10 @@ export class Address {
|
|||||||
return new Address(data.kind, data.pubkey, data.identifier, data.relays)
|
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) => {
|
asTag = (mark?: string) => {
|
||||||
const tag = ["a", this.asTagValue(), this.relays[0] || ""]
|
const tag = ["a", this.asRaw(), this.relays[0] || ""]
|
||||||
|
|
||||||
if (mark) {
|
if (mark) {
|
||||||
tag.push(mark)
|
tag.push(mark)
|
||||||
|
|||||||
+11
-5
@@ -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 {verifyEvent, getEventHash} from 'nostr-tools'
|
||||||
import {cached} from "./LRUCache"
|
import {cached} from "./LRUCache"
|
||||||
import {now} from './Tools'
|
import {now} from './Tools'
|
||||||
@@ -16,6 +16,15 @@ export type CreateEventOpts = {
|
|||||||
export const createEvent = (kind: number, {content = "", tags = [], created_at = now()}: CreateEventOpts) =>
|
export const createEvent = (kind: number, {content = "", tags = [], created_at = now()}: CreateEventOpts) =>
|
||||||
({kind, content, tags, created_at})
|
({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<string, boolean, [Event]>({
|
export const hasValidSignature = cached<string, boolean, [Event]>({
|
||||||
maxSize: 10000,
|
maxSize: 10000,
|
||||||
getKey: ([e]: [Event]) => {
|
getKey: ([e]: [Event]) => {
|
||||||
@@ -34,15 +43,12 @@ export const hasValidSignature = cached<string, boolean, [Event]>({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
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 getIdOrAddress = (e: Rumor) => isReplaceable(e) ? getAddress(e) : e.id
|
||||||
|
|
||||||
export const getIdAndAddress = (e: Rumor) => isReplaceable(e) ? [e.id, 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 isEphemeral = (e: EventTemplate) => isEphemeralKind(e.kind)
|
||||||
|
|
||||||
export const isReplaceable = (e: EventTemplate) => isReplaceableKind(e.kind)
|
export const isReplaceable = (e: EventTemplate) => isReplaceableKind(e.kind)
|
||||||
|
|||||||
+14
-2
@@ -3,6 +3,10 @@ import {last} from './Tools'
|
|||||||
export class Fluent<T> {
|
export class Fluent<T> {
|
||||||
constructor(readonly xs: T[]) {}
|
constructor(readonly xs: T[]) {}
|
||||||
|
|
||||||
|
static create() {
|
||||||
|
return this.from([])
|
||||||
|
}
|
||||||
|
|
||||||
static from<T>(xs: Iterable<T>) {
|
static from<T>(xs: Iterable<T>) {
|
||||||
return new Fluent<T>(Array.from(xs))
|
return new Fluent<T>(Array.from(xs))
|
||||||
}
|
}
|
||||||
@@ -23,6 +27,8 @@ export class Fluent<T> {
|
|||||||
|
|
||||||
exists = () => this.xs.length > 0
|
exists = () => this.xs.length > 0
|
||||||
|
|
||||||
|
has = (v: T) => this.xs.includes(v)
|
||||||
|
|
||||||
every = (f: (t: T) => boolean) => this.xs.every(f)
|
every = (f: (t: T) => boolean) => this.xs.every(f)
|
||||||
|
|
||||||
some = (f: (t: T) => boolean) => this.xs.some(f)
|
some = (f: (t: T) => boolean) => this.xs.some(f)
|
||||||
@@ -43,11 +49,17 @@ export class Fluent<T> {
|
|||||||
|
|
||||||
map = (f: (t: T) => T) => this.clone(this.xs.map(f))
|
map = (f: (t: T) => T) => this.clone(this.xs.map(f))
|
||||||
|
|
||||||
mapTo = <U>(f: (t: T) => U) => new Fluent(this.xs.map(f))
|
mapTo = <U>(f: (t: T) => U) => Fluent.from(this.xs.map(f))
|
||||||
|
|
||||||
flatMap = <U>(f: (t: T) => U[]) => new Fluent(this.xs.flatMap(f))
|
flatMap = <U>(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))
|
concat = (xs: T[]) => this.clone(this.xs.concat(xs))
|
||||||
|
|
||||||
append = (x: T) => this.concat([x])
|
append = (x: T) => this.concat([x])
|
||||||
|
|
||||||
|
prepend = (x: T) => this.clone([x].concat(this.xs))
|
||||||
}
|
}
|
||||||
|
|||||||
+246
-112
@@ -1,14 +1,26 @@
|
|||||||
import type {Event} from 'nostr-tools'
|
import type {EventTemplate, UnsignedEvent} from 'nostr-tools'
|
||||||
import {Tags} from './Tags'
|
import type {Rumor} from './Events'
|
||||||
import {nth, first} from '../util/Tools'
|
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 = {
|
export type RouterOptions = {
|
||||||
getUserPubkey: () => string | null
|
getUserPubkey: () => string | null
|
||||||
getGroupRelayTags: (address: string) => string[][]
|
getGroupRelays: (address: string) => string[]
|
||||||
getCommunityRelayTags: (address: string) => string[][]
|
getCommunityRelays: (address: string) => string[]
|
||||||
getPubkeyRelayTags: (pubkey: string) => string[][]
|
getPubkeyInboxRelays: (pubkey: string) => string[]
|
||||||
getFallbackRelayTags: () => string[][]
|
getPubkeyOutboxRelays: (pubkey: string) => string[]
|
||||||
|
getFallbackInboxRelays: () => string[]
|
||||||
|
getFallbackOutboxRelays: () => string[]
|
||||||
getRelayQuality?: (url: string) => number
|
getRelayQuality?: (url: string) => number
|
||||||
|
getDefaultLimit: () => number
|
||||||
}
|
}
|
||||||
|
|
||||||
// - Fetch from and publish to non-shareable relays, but don't use them for hints
|
// - 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
|
// Utilities derived from options
|
||||||
|
|
||||||
getGroupRelayUrls = (address: string) =>
|
getAllPubkeyRelays = (pubkey: string) =>
|
||||||
this.options.getGroupRelayTags(address).map(nth(1))
|
[
|
||||||
|
...this.options.getPubkeyInboxRelays(pubkey),
|
||||||
|
...this.options.getPubkeyOutboxRelays(pubkey),
|
||||||
|
]
|
||||||
|
|
||||||
getCommunityRelayUrls = (address: string) =>
|
getUserInboxRelays = () => {
|
||||||
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) => {
|
|
||||||
const pubkey = this.options.getUserPubkey()
|
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()
|
const pubkey = this.options.getUserPubkey()
|
||||||
|
|
||||||
return pubkey ? this.getPubkeyRelayUrls(pubkey, mode) : []
|
return pubkey ? this.options.getPubkeyOutboxRelays(pubkey) : []
|
||||||
}
|
}
|
||||||
|
|
||||||
getEventGroupOrCommunityRelayUrlGroups = (event: Event, otherGroups: string[][]) => {
|
getAllUserRelays = () => {
|
||||||
const groupAddresses = Tags.fromEvent(event).groups().valueOf()
|
const pubkey = this.options.getUserPubkey()
|
||||||
|
|
||||||
if (groupAddresses.length > 0) {
|
return pubkey ? this.getAllPubkeyRelays(pubkey) : []
|
||||||
return groupAddresses.map(this.getGroupRelayUrls)
|
}
|
||||||
}
|
|
||||||
|
getEventContextRelayGroups = (event: EventTemplate) => {
|
||||||
|
const addresses = Tags.fromEvent(event).context().values().valueOf()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...Tags.fromEvent(event).communities().valueOf().map(this.getCommunityRelayUrls),
|
...addresses.filter(isCommunityAddress).map(this.options.getCommunityRelays),
|
||||||
...otherGroups,
|
...addresses.filter(isGroupAddress).map(this.options.getGroupRelays),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,19 +69,16 @@ export class Router {
|
|||||||
getGroupScores = (groups: string[][]) => {
|
getGroupScores = (groups: string[][]) => {
|
||||||
const scores: RouteScenarioScores = {}
|
const scores: RouteScenarioScores = {}
|
||||||
|
|
||||||
// TODO: see if weighting earlier groups slightly heavier improves things
|
groups.forEach((urls, i) => {
|
||||||
for (const urls of groups) {
|
for (const url of shuffle(uniq(urls))) {
|
||||||
urls.forEach((url, i) => {
|
|
||||||
const score = 1 / (i + 1) / urls.length
|
|
||||||
|
|
||||||
if (!scores[url]) {
|
if (!scores[url]) {
|
||||||
scores[url] = {score: 0, count: 0}
|
scores[url] = {score: 0, count: 0}
|
||||||
}
|
}
|
||||||
|
|
||||||
scores[url].score += score
|
scores[url].score += 1 / (i + 1)
|
||||||
scores[url].count += 1
|
scores[url].count += 1
|
||||||
})
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
// Use log-sum-exp to get a a weighted sum
|
// Use log-sum-exp to get a a weighted sum
|
||||||
for (const [url, score] of Object.entries(scores)) {
|
for (const [url, score] of Object.entries(scores)) {
|
||||||
@@ -91,95 +93,210 @@ export class Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
urlsFromScores = (limit: number, scores: RouteScenarioScores) =>
|
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[][]) =>
|
groupsToUrls = (limit: number, groups: string[][]) => this.urlsFromScores(limit, this.getGroupScores(groups))
|
||||||
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
|
// Routing scenarios
|
||||||
|
|
||||||
FetchAllDirectMessage = () => new RouterScenario(this, {
|
Broadcast = () => this.scenario({
|
||||||
fallbackPolicy: useMinimalFallbacks("read"),
|
fallbackPolicy: useMinimalFallbacks(RelayMode.Outbox),
|
||||||
getGroups: () => [this.getUserRelayUrls()],
|
getGroups: () => [this.getAllUserRelays()],
|
||||||
})
|
})
|
||||||
|
|
||||||
FetchDirectMessages = (pubkey: string) => new RouterScenario(this, {
|
Aggregate = () => this.scenario({
|
||||||
fallbackPolicy: useMinimalFallbacks("read"),
|
fallbackPolicy: useMinimalFallbacks(RelayMode.Inbox),
|
||||||
getGroups: () => [this.getUserRelayUrls(), this.getPubkeyRelayUrls(pubkey)],
|
getGroups: () => [this.getAllUserRelays()],
|
||||||
})
|
})
|
||||||
|
|
||||||
PublishDirectMessage = (pubkey: string) => new RouterScenario(this, {
|
NoteToSelf = () => this.scenario({
|
||||||
fallbackPolicy: useMinimalFallbacks("write"),
|
fallbackPolicy: useMaximalFallbacks(RelayMode.Inbox),
|
||||||
getGroups: () => [this.getUserRelayUrls("write"), this.getPubkeyRelayUrls(pubkey, "read")],
|
getGroups: () => [this.getUserInboxRelays()],
|
||||||
})
|
})
|
||||||
|
|
||||||
FetchPubkeyEvents = (pubkey: string) => new RouterScenario(this, {
|
FetchAllMessages = () => this.scenario({
|
||||||
fallbackPolicy: useMaximalFallbacks("read"),
|
fallbackPolicy: useMinimalFallbacks(RelayMode.Inbox),
|
||||||
getGroups: () => [this.getPubkeyRelayUrls(pubkey, "write")],
|
getGroups: () => [this.getAllUserRelays()],
|
||||||
})
|
})
|
||||||
|
|
||||||
FetchEvent = (event: Event) => new RouterScenario(this, {
|
FetchMessages = (pubkeys: string[]) => this.scenario({
|
||||||
fallbackPolicy: useMaximalFallbacks("read"),
|
fallbackPolicy: useMinimalFallbacks(RelayMode.Inbox),
|
||||||
getGroups: () =>
|
getGroups: () => [
|
||||||
this.getEventGroupOrCommunityRelayUrlGroups(event, [
|
this.getAllUserRelays(),
|
||||||
this.getPubkeyRelayUrls(event.pubkey, "write"),
|
...pubkeys.map(this.getAllPubkeyRelays)
|
||||||
]),
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
FetchEventChildren = (event: Event) => new RouterScenario(this, {
|
PublishMessage = (pubkeys: string[]) => this.scenario({
|
||||||
fallbackPolicy: useMaximalFallbacks("read"),
|
fallbackPolicy: useMinimalFallbacks(RelayMode.Outbox),
|
||||||
getGroups: () =>
|
getGroups: () => [
|
||||||
this.getEventGroupOrCommunityRelayUrlGroups(event, [
|
this.getUserOutboxRelays(),
|
||||||
this.getPubkeyRelayUrls(event.pubkey, "read"),
|
...pubkeys.map(this.options.getPubkeyInboxRelays)
|
||||||
]),
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
FetchEventParent = (event: Event) => new RouterScenario(this, {
|
FetchEvent = (event: UnsignedEvent) => this.scenario({
|
||||||
fallbackPolicy: useMaximalFallbacks("read"),
|
fallbackPolicy: useMaximalFallbacks(RelayMode.Inbox),
|
||||||
getGroups: () =>
|
getGroups: () => [
|
||||||
this.getEventGroupOrCommunityRelayUrlGroups(event, [
|
this.options.getPubkeyOutboxRelays(event.pubkey),
|
||||||
Tags.fromEvent(event).replies().relays().valueOf(),
|
...this.getEventContextRelayGroups(event),
|
||||||
this.getPubkeyRelayUrls(event.pubkey, "read"),
|
],
|
||||||
]),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
FetchEventRoot = (event: Event) => new RouterScenario(this, {
|
FetchEventChildren = (event: UnsignedEvent) => this.scenario({
|
||||||
fallbackPolicy: useMaximalFallbacks("read"),
|
fallbackPolicy: useMaximalFallbacks(RelayMode.Inbox),
|
||||||
getGroups: () =>
|
getGroups: () => [
|
||||||
this.getEventGroupOrCommunityRelayUrlGroups(event, [
|
this.options.getPubkeyInboxRelays(event.pubkey),
|
||||||
Tags.fromEvent(event).roots().relays().valueOf(),
|
...this.getEventContextRelayGroups(event),
|
||||||
this.getPubkeyRelayUrls(event.pubkey, "read"),
|
],
|
||||||
]),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
PublishEvent = (event: Event) => new RouterScenario(this, {
|
FetchEventParent = (event: UnsignedEvent) => this.scenario({
|
||||||
fallbackPolicy: useMinimalFallbacks("write"),
|
fallbackPolicy: useMaximalFallbacks(RelayMode.Inbox),
|
||||||
getGroups: () =>
|
getGroups: () => [
|
||||||
this.getEventGroupOrCommunityRelayUrlGroups(event, [
|
Tags.fromEvent(event).replies().relays().valueOf(),
|
||||||
this.getPubkeyRelayUrls(event.pubkey, "write"),
|
this.options.getPubkeyInboxRelays(event.pubkey),
|
||||||
...Tags.fromEvent(event).whereKey("p").values().valueOf().map((pk: string) => this.getPubkeyRelayUrls(pk, "read")),
|
...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(),
|
fallbackPolicy: useNoFallbacks(),
|
||||||
getGroups: () => [this.getGroupRelayUrls(address)],
|
getGroups: () => [this.options.getGroupRelays(address)],
|
||||||
})
|
})
|
||||||
|
|
||||||
PublishToGroup = (address: string) => new RouterScenario(this, {
|
PublishToGroup = (address: string) => this.scenario({
|
||||||
fallbackPolicy: useNoFallbacks(),
|
fallbackPolicy: useNoFallbacks(),
|
||||||
getGroups: () => [this.getGroupRelayUrls(address)],
|
getGroups: () => [this.options.getGroupRelays(address)],
|
||||||
})
|
})
|
||||||
|
|
||||||
FetchFromCommunity = (address: string) => new RouterScenario(this, {
|
FetchFromCommunity = (address: string) => this.scenario({
|
||||||
fallbackPolicy: useMaximalFallbacks("read"),
|
fallbackPolicy: useMaximalFallbacks(RelayMode.Inbox),
|
||||||
getGroups: () => [this.getCommunityRelayUrls(address)],
|
getGroups: () => [this.options.getCommunityRelays(address)],
|
||||||
})
|
})
|
||||||
|
|
||||||
PublishToCommunity = (address: string) => new RouterScenario(this, {
|
PublishToCommunity = (address: string) => this.scenario({
|
||||||
fallbackPolicy: useMaximalFallbacks("write"),
|
fallbackPolicy: useMaximalFallbacks(RelayMode.Outbox),
|
||||||
getGroups: () => [this.getCommunityRelayUrls(address)],
|
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
|
// Router Scenario
|
||||||
@@ -194,24 +311,41 @@ export type RouteScenarioScores = Record<string, {score: number, count: number}>
|
|||||||
export class RouterScenario {
|
export class RouterScenario {
|
||||||
constructor(readonly router: Router, readonly options: RouterScenarioOptions) {}
|
constructor(readonly router: Router, readonly options: RouterScenarioOptions) {}
|
||||||
|
|
||||||
addFallbackUrls = (limit: number, urls: string[]) => {
|
getFallbackRelays = () => {
|
||||||
if (urls.length < limit) {
|
switch (this.options.fallbackPolicy.mode) {
|
||||||
const {mode, getLimit} = this.options.fallbackPolicy
|
case RelayMode.Inbox:
|
||||||
const fallbackRelayTags = this.router.options.getFallbackRelayTags()
|
return this.router.options.getFallbackInboxRelays()
|
||||||
const fallbackUrls = Tags.from(fallbackRelayTags).whereMark(mode).values().valueOf()
|
case RelayMode.Outbox:
|
||||||
const fallbackLimit = getLimit(limit, urls)
|
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
|
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 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))
|
getUrl = () => first(this.getUrls(1))
|
||||||
@@ -219,12 +353,12 @@ export class RouterScenario {
|
|||||||
|
|
||||||
// Fallback Policy
|
// Fallback Policy
|
||||||
|
|
||||||
class FallbackPolicy {
|
export class FallbackPolicy {
|
||||||
constructor(readonly mode: string, readonly getLimit: (limit: number, urls: string[]) => number) {}
|
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))
|
||||||
|
|||||||
+146
-36
@@ -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 {Fluent} from './Fluent'
|
||||||
import type {OmitAllStatics} from './Tools'
|
import type {OmitStatics} from './Tools'
|
||||||
import {last} from './Tools'
|
import {last} from './Tools'
|
||||||
import {isShareableRelayUrl} from './Relays'
|
import {isShareableRelayUrl} from './Relays'
|
||||||
import {isCommunityAddress, isGroupAddress, isCommunityOrGroupAddress} from './Address'
|
import {isCommunityAddress, isGroupAddress, isContextAddress} from './Address'
|
||||||
|
|
||||||
export class Tag extends (Fluent<string> as OmitAllStatics<typeof Fluent<string>>) {
|
export class Tag extends (Fluent<string> as OmitStatics<typeof Fluent<string>, 'from'>) {
|
||||||
static from(xs: Iterable<string>) {
|
static from(xs: Iterable<string>) {
|
||||||
return new Tag(Array.from(xs))
|
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
|
valueOf = () => this.xs
|
||||||
|
|
||||||
key = () => this.xs[0]
|
key = () => this.xs[0]
|
||||||
@@ -19,19 +35,25 @@ export class Tag extends (Fluent<string> as OmitAllStatics<typeof Fluent<string>
|
|||||||
mark = () => last(this.xs.slice(2))
|
mark = () => last(this.xs.slice(2))
|
||||||
|
|
||||||
entry = () => this.xs.slice(0, 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<Tag> as OmitAllStatics<typeof Fluent<Tag>>) {
|
export class Tags extends (Fluent<Tag> as OmitStatics<typeof Fluent<Tag>, 'from'>) {
|
||||||
static from(p: Iterable<string[]>) {
|
static from(p: Iterable<string[]>) {
|
||||||
return new Tags(Array.from(p).map(Tag.from))
|
return new Tags(Array.from(p).map(Tag.from))
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromEvent(event: EventTemplate) {
|
static fromEvent(event: EventTemplate) {
|
||||||
return Tags.from(event.tags)
|
return Tags.from(event.tags || [])
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromEvents(events: Iterable<EventTemplate>) {
|
static fromEvents(events: EventTemplate[]) {
|
||||||
return Tags.from(Array.from(events).flatMap((e: EventTemplate) => e.tags))
|
return Tags.from(events.flatMap(e => e.tags || []))
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -43,9 +65,17 @@ export class Tags extends (Fluent<Tag> as OmitAllStatics<typeof Fluent<Tag>>) {
|
|||||||
|
|
||||||
whereMark = (mark: string) => this.filter(t => t.mark() === mark)
|
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())
|
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())
|
marks = () => this.mapTo(t => t.mark())
|
||||||
|
|
||||||
@@ -55,44 +85,124 @@ export class Tags extends (Fluent<Tag> as OmitAllStatics<typeof Fluent<Tag>>) {
|
|||||||
|
|
||||||
topics = () => this.whereKey("t").values().map((t: string) => t.replace(/^#/, ""))
|
topics = () => this.whereKey("t").values().map((t: string) => t.replace(/^#/, ""))
|
||||||
|
|
||||||
getAncestorsLegacy(this: Tags) {
|
ancestors = () => {
|
||||||
// Legacy only supports e tags. Normalize their length to 3
|
const tags = this.filter(t => ["a", "e"].includes(t.key()) && !isContextAddress(t.value()))
|
||||||
const eTags =
|
const roots: string[][] = []
|
||||||
this
|
const replies: string[][] = []
|
||||||
.whereKey("e")
|
const mentions: string[][] = []
|
||||||
.map((t: Tag) => t.concat([""]).slice(0, 3))
|
|
||||||
|
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 {
|
return {
|
||||||
roots: eTags.slice(0, 1),
|
roots: Tags.from(roots),
|
||||||
replies: eTags.slice(-1),
|
replies: Tags.from(replies),
|
||||||
mentions: eTags.slice(1, -1),
|
mentions: Tags.from(mentions),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getAncestors = (key?: string) => {
|
roots = () => this.ancestors().roots
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
const eTags = this.whereKey("e")
|
replies = () => this.ancestors().replies
|
||||||
const aTags = this.whereKey("a").reject((t: Tag) => isCommunityOrGroupAddress(t.value()))
|
|
||||||
const allTags = eTags.concat(aTags.xs)
|
|
||||||
|
|
||||||
return {
|
mentions = () => this.ancestors().mentions
|
||||||
roots: allTags.whereMark('root').map((t: Tag) => t.take(3)),
|
|
||||||
replies: allTags.whereMark('reply').map((t: Tag) => t.take(3)),
|
root = () => {
|
||||||
mentions: allTags.whereMark('mention').map((t: Tag) => t.take(3)),
|
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<string, string> = {}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,20 @@ export const flatten = <T>(xs: T[]) => xs.flatMap(identity)
|
|||||||
|
|
||||||
export const uniq = <T>(xs: T[]) => Array.from(new Set(xs))
|
export const uniq = <T>(xs: T[]) => Array.from(new Set(xs))
|
||||||
|
|
||||||
|
export const shuffle = <T>(xs: T[]): T[] => xs.sort(() => Math.random() > 0.5 ? 1 : -1)
|
||||||
|
|
||||||
export const isIterable = (x: any) => Symbol.iterator in Object(x)
|
export const isIterable = (x: any) => Symbol.iterator in Object(x)
|
||||||
|
|
||||||
export const toIterable = (x: any) => isIterable(x) ? x : [x]
|
export const toIterable = (x: any) => isIterable(x) ? x : [x]
|
||||||
|
|
||||||
export const stripProtocol = (url: string) => url.replace(/.*:\/\//, "")
|
export const stripProtocol = (url: string) => url.replace(/.*:\/\//, "")
|
||||||
|
|
||||||
|
// https://github.com/microsoft/TypeScript/issues/4628#issuecomment-1147905253
|
||||||
|
export type OmitStatics<T, S extends string> =
|
||||||
|
T extends {new(...args: infer A): infer R} ?
|
||||||
|
{new(...args: A): R}&Omit<T, S> :
|
||||||
|
Omit<T, S>;
|
||||||
|
|
||||||
// https://github.com/microsoft/TypeScript/issues/4628#issuecomment-1147905253
|
// https://github.com/microsoft/TypeScript/issues/4628#issuecomment-1147905253
|
||||||
export type OmitAllStatics<T extends {new(...args: any[]): any, prototype: any}> =
|
export type OmitAllStatics<T extends {new(...args: any[]): any, prototype: any}> =
|
||||||
T extends {new(...args: infer A): infer R, prototype: infer P} ?
|
T extends {new(...args: infer A): infer R, prototype: infer P} ?
|
||||||
|
|||||||
Reference in New Issue
Block a user