Switch to monorepo setup
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
build
|
||||
normalize-url
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {UnsignedEvent} from 'nostr-tools'
|
||||
import {nip19} from 'nostr-tools'
|
||||
import {GROUP_DEFINITION, COMMUNITY_DEFINITION} from './Kinds'
|
||||
|
||||
export type Address = {
|
||||
kind: number,
|
||||
pubkey: string
|
||||
identifier: string
|
||||
relays: string[]
|
||||
}
|
||||
|
||||
// Plain text format
|
||||
|
||||
export const decodeAddress = (a: string, relays: string[] = []): Address => {
|
||||
const [kind, pubkey, identifier = ""] = a.split(":")
|
||||
|
||||
return {kind: parseInt(kind), pubkey, identifier, relays}
|
||||
}
|
||||
|
||||
export const encodeAddress = (a: Address) => [a.kind, a.pubkey, a.identifier].join(":")
|
||||
|
||||
// Naddr encoding
|
||||
|
||||
export const addressFromNaddr = (naddr: string): Address => {
|
||||
let type
|
||||
let data = {} as any
|
||||
try {
|
||||
({type, data} = nip19.decode(naddr) as {
|
||||
type: "naddr"
|
||||
data: any
|
||||
})
|
||||
} catch (e) {
|
||||
// pass
|
||||
}
|
||||
|
||||
if (type !== "naddr") {
|
||||
throw new Error(`Invalid naddr ${naddr}`)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export const addressToNaddr = (a: Address): string => nip19.naddrEncode(a)
|
||||
|
||||
// Get from event, encode to filter
|
||||
|
||||
export const getIdentifier = (e: UnsignedEvent) => e.tags.find(t => t[0] === "d")?.[1] || ""
|
||||
|
||||
export const addressFromEvent = (e: UnsignedEvent, relays: string[] = []) =>
|
||||
({kind: e.kind, pubkey: e.pubkey, identifier: getIdentifier(e), relays})
|
||||
|
||||
export const addressToFilter = (a: Address) =>
|
||||
({kinds: [a.kind], authors: [a.pubkey], "#d": [a.identifier]})
|
||||
|
||||
// Utils
|
||||
|
||||
export const isGroupAddress = (a: Address) => a.kind === GROUP_DEFINITION
|
||||
|
||||
export const isCommunityAddress = (a: Address) => a.kind === COMMUNITY_DEFINITION
|
||||
|
||||
export const isContextAddress = (a: Address) => [GROUP_DEFINITION, COMMUNITY_DEFINITION].includes(a.kind)
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {Event, EventTemplate, UnsignedEvent} from 'nostr-tools'
|
||||
import {verifyEvent, getEventHash} from 'nostr-tools'
|
||||
import {cached, now} from '@coracle.social/lib'
|
||||
import {Tags} from './Tags'
|
||||
import {addressFromEvent, encodeAddress} from './Address'
|
||||
import {isEphemeralKind, isReplaceableKind, isPlainReplaceableKind, isParameterizedReplaceableKind} from './Kinds'
|
||||
|
||||
export type Rumor = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at' | 'pubkey' | 'id'>
|
||||
|
||||
export type CreateEventOpts = {
|
||||
content?: string
|
||||
tags?: string[][]
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
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 asEvent = ({kind, tags, content, created_at, pubkey, id, sig}: Event): Event =>
|
||||
({kind, tags, content, created_at, pubkey, id, sig})
|
||||
|
||||
export const hasValidSignature = cached<string, boolean, [Event]>({
|
||||
maxSize: 10000,
|
||||
getKey: ([e]: [Event]) => {
|
||||
try {
|
||||
return [getEventHash(e), e.sig].join(":")
|
||||
} catch (err) {
|
||||
return 'invalid'
|
||||
}
|
||||
},
|
||||
getValue: ([e]: [Event]) => {
|
||||
try {
|
||||
return verifyEvent(e)
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const getAddress = (e: UnsignedEvent) => encodeAddress(addressFromEvent(e))
|
||||
|
||||
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 isEphemeral = (e: EventTemplate) => isEphemeralKind(e.kind)
|
||||
|
||||
export const isReplaceable = (e: EventTemplate) => isReplaceableKind(e.kind)
|
||||
|
||||
export const isPlainReplaceable = (e: EventTemplate) => isPlainReplaceableKind(e.kind)
|
||||
|
||||
export const isParameterizedReplaceable = (e: EventTemplate) => isParameterizedReplaceableKind(e.kind)
|
||||
|
||||
export const isChildOf = (child: EventTemplate, parent: Rumor) => {
|
||||
const {roots, replies} = Tags.fromEvent(child).ancestors()
|
||||
const parentIds = (replies.exists() ? replies : roots).values().valueOf()
|
||||
|
||||
return getIdAndAddress(parent).some(x => parentIds.includes(x))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {Event} from 'nostr-tools'
|
||||
import {matchFilter as nostrToolsMatchFilter} from 'nostr-tools'
|
||||
|
||||
export type Filter = {
|
||||
ids?: string[]
|
||||
kinds?: number[]
|
||||
authors?: string[]
|
||||
since?: number
|
||||
until?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
[key: `#${string}`]: string[]
|
||||
}
|
||||
|
||||
export const matchFilter = (filter: Filter, event: Event) => {
|
||||
if (!nostrToolsMatchFilter(filter, event)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filter.search) {
|
||||
const content = event.content.toLowerCase()
|
||||
const terms = filter.search.toLowerCase().split(/\s+/g)
|
||||
|
||||
for (const term of terms) {
|
||||
if (content.includes(term)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const matchFilters = (filters: Filter[], event: Event) => {
|
||||
for (const filter of filters) {
|
||||
if (matchFilter(filter, event)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {between} from '@coracle.social/lib'
|
||||
|
||||
export const isEphemeralKind = (kind: number) => between(19999, 29999, kind)
|
||||
|
||||
export const isPlainReplaceableKind = (kind: number) => between(9999, 20000, kind)
|
||||
|
||||
export const isParameterizedReplaceableKind = (kind: number) => between(29999, 40000, kind)
|
||||
|
||||
export const isReplaceableKind = (kind: number) => isPlainReplaceableKind(kind) || isParameterizedReplaceableKind(kind)
|
||||
|
||||
export const PROFILE = 0
|
||||
export const NOTE = 1
|
||||
export const RELAY = 2
|
||||
export const DM = 4
|
||||
export const EVENT_DELETION = 5
|
||||
export const REPOST = 6
|
||||
export const REACTION = 7
|
||||
export const BADGE_AWARD = 8
|
||||
export const GENERIC_REPOST = 16
|
||||
export const CHANNEL_CREATION = 40
|
||||
export const CHANNEL_METADATA = 41
|
||||
export const CHANNEL_MESSAGE = 42
|
||||
export const CHANNEL_HIDE_MESSAGE = 43
|
||||
export const CHANNEL_MUTE_USER = 44
|
||||
export const OPEN_TIMESTAMP = 1040
|
||||
export const GIFT_WRAP = 1059
|
||||
export const FILE_METADATA = 1063
|
||||
export const LIVE_CHAT_MESSAGE = 1311
|
||||
export const PROBLEM_TRACKER = 1971
|
||||
export const REPORT = 1984
|
||||
export const LABEL = 1985
|
||||
export const COMMUNITY_POST_APPROVAL = 4550
|
||||
export const JOB_REQUEST = 5999
|
||||
export const JOB_RESULT = 6999
|
||||
export const JOB_FEEDBACK = 7000
|
||||
export const ZAP_GOAL = 9041
|
||||
export const ZAP_REQUEST = 9734
|
||||
export const ZAP_RESPONSE = 9735
|
||||
export const HIGHLIGHT = 9802
|
||||
export const USER_LIST_MUTES = 10000
|
||||
export const USER_LIST_PINS = 10001
|
||||
export const USER_LIST_RELAYS = 10002
|
||||
export const USER_LIST_BOOKMARKS = 10003
|
||||
export const USER_LIST_COMMUNITIES = 10004
|
||||
export const USER_LIST_PUBLIC_CHATS = 10005
|
||||
export const USER_LIST_BLOCKED_RELAYS = 10006
|
||||
export const USER_LIST_SEARCH_RELAYS = 10007
|
||||
export const USER_LIST_INTERESTS = 10015
|
||||
export const USER_LIST_EMOJIS = 10030
|
||||
export const LIGHTNING_PUB_RPC = 21000
|
||||
export const CLIENT_AUTH = 22242
|
||||
export const NWC_INFO = 13194
|
||||
export const NWC_REQUEST = 23194
|
||||
export const NWC_RESPONSE = 23195
|
||||
export const NOSTR_CONNECT = 24133
|
||||
export const HTTP_AUTH = 27235
|
||||
export const LIST_FOLLOWS = 3
|
||||
export const LIST_PEOPLE = 30000
|
||||
export const LIST_GENERIC = 30001
|
||||
export const LIST_RELAYS = 30002
|
||||
export const LIST_BOOKMARKS = 30003
|
||||
export const LIST_CURATIONS = 30004
|
||||
export const PROFILE_BADGES = 30008
|
||||
export const BADGE_DEFINITION = 30009
|
||||
export const LIST_EMOJIS = 30030
|
||||
export const LIST_INTERESTS = 30015
|
||||
export const LONG_FORM_ARTICLE = 30023
|
||||
export const LONG_FORM_ARTICLE_DRAFT = 30024
|
||||
export const APPLICATION = 30078
|
||||
export const LIVE_EVENT = 30311
|
||||
export const USER_STATUSES = 30315
|
||||
export const CLASSIFIED_LISTING = 30402
|
||||
export const DRAFT_CLASSIFIED_LISTING = 30403
|
||||
export const CALENDAR = 31924
|
||||
export const CALENDAR_EVENT_DATE = 31922
|
||||
export const CALENDAR_EVENT_TIME = 31923
|
||||
export const CALENDAR_EVENT_RSVP = 31925
|
||||
export const HANDLER_RECOMMENDATION = 31989
|
||||
export const HANDLER_INFORMATION = 31990
|
||||
export const COMMUNITY_DEFINITION = 34550
|
||||
export const GROUP_DEFINITION = 35834
|
||||
@@ -0,0 +1,3 @@
|
||||
export const fromNostrURI = (s: string) => s.replace(/^[\w+]+:\/?\/?/, "")
|
||||
|
||||
export const toNostrURI = (s: string) => `nostr:${s}`
|
||||
@@ -0,0 +1,33 @@
|
||||
import {normalizeUrl, stripProtocol} from '@coracle.social/lib'
|
||||
|
||||
export const isShareableRelayUrl = (url: string) =>
|
||||
Boolean(
|
||||
typeof url === 'string' &&
|
||||
// Is it actually a websocket url and has a dot
|
||||
url.match(/^wss:\/\/.+\..+/) &&
|
||||
// Sometimes bugs cause multiple relays to get concatenated
|
||||
url.match(/:\/\//g)?.length === 1 &&
|
||||
// It shouldn't have any whitespace, url-encoded or otherwise
|
||||
!url.match(/\s|%/) &&
|
||||
// Don't match stuff with a port number
|
||||
!url.slice(6).match(/:\d+/) &&
|
||||
// Don't match raw ip addresses
|
||||
!url.slice(6).match(/\d+\.\d+\.\d+\.\d+/) &&
|
||||
// Skip nostr.wine's virtual relays
|
||||
!url.slice(6).match(/\/npub/)
|
||||
)
|
||||
|
||||
export const normalizeRelayUrl = (url: string) => {
|
||||
// Use our library to normalize
|
||||
url = normalizeUrl(url, {stripHash: true, stripAuthentication: false})
|
||||
|
||||
// Strip the protocol since only wss works
|
||||
url = stripProtocol(url)
|
||||
|
||||
// Urls without pathnames are supposed to have a trailing slash
|
||||
if (!url.includes("/")) {
|
||||
url += "/"
|
||||
}
|
||||
|
||||
return "wss://" + url
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import type {EventTemplate, UnsignedEvent} from 'nostr-tools'
|
||||
import {first, uniq, shuffle} from '@coracle.social/lib'
|
||||
import type {Rumor} from './Events'
|
||||
import {getAddress, isReplaceable} from './Events'
|
||||
import {Tag, Tags} from './Tags'
|
||||
import {GROUP_DEFINITION, COMMUNITY_DEFINITION} from './Kinds'
|
||||
import {addressFromEvent, decodeAddress} from './Address'
|
||||
|
||||
const isGroupAddress = (a: string) => decodeAddress(a).kind === GROUP_DEFINITION
|
||||
|
||||
const isCommunityAddress = (a: string) => decodeAddress(a).kind === COMMUNITY_DEFINITION
|
||||
|
||||
export enum RelayMode {
|
||||
Read = "read",
|
||||
Write = "write",
|
||||
}
|
||||
|
||||
export type RouterOptions = {
|
||||
getUserPubkey: () => string | null
|
||||
getGroupRelays: (address: string) => string[]
|
||||
getCommunityRelays: (address: string) => string[]
|
||||
getPubkeyRelays: (pubkey: string, mode?: RelayMode) => string[]
|
||||
getDefaultRelays: (mode?: RelayMode) => string[]
|
||||
getRelayQuality?: (url: string) => number
|
||||
getDefaultLimit: () => number
|
||||
}
|
||||
|
||||
export type RouteScores = Record<string, {score: number, count: number}>
|
||||
|
||||
export type FallbackPolicy = (urls: string[], limit: number) => number
|
||||
|
||||
export class Router {
|
||||
constructor(readonly options: RouterOptions) {}
|
||||
|
||||
// Utilities derived from options
|
||||
|
||||
getUserRelays = (mode?: RelayMode) => {
|
||||
const pubkey = this.options.getUserPubkey()
|
||||
|
||||
return pubkey ? this.options.getPubkeyRelays(pubkey, mode) : []
|
||||
}
|
||||
|
||||
getContextRelayGroups = (event: EventTemplate) => {
|
||||
const addresses = Tags.fromEvent(event).context().values().valueOf()
|
||||
|
||||
return [
|
||||
...addresses.filter(isCommunityAddress).map(this.options.getCommunityRelays),
|
||||
...addresses.filter(isGroupAddress).map(this.options.getGroupRelays),
|
||||
]
|
||||
}
|
||||
|
||||
// Utilities for processing hints
|
||||
|
||||
scoreGroups = (groups: string[][]) => {
|
||||
const scores: RouteScores = {}
|
||||
|
||||
groups.filter(g => g?.length > 0).forEach((urls, i) => {
|
||||
for (const url of shuffle(uniq(urls))) {
|
||||
if (!scores[url]) {
|
||||
scores[url] = {score: 0, count: 0}
|
||||
}
|
||||
|
||||
scores[url].score += 1 / (i + 1)
|
||||
scores[url].count += 1
|
||||
}
|
||||
})
|
||||
|
||||
for (const [url, score] of Object.entries(scores)) {
|
||||
const quality = this.options.getRelayQuality?.(url) || 1
|
||||
|
||||
score.score = score.score * Math.cbrt(score.count) * quality
|
||||
}
|
||||
|
||||
const items = Object.entries(scores)
|
||||
.filter(([url, {score}]) => score > 0)
|
||||
.sort((a, b) => b[1].score - a[1].score)
|
||||
.map(([url, {score, count}]) => ({url, score, count}))
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
scenario = (groups: string[][]) => new RouterScenario(this, groups)
|
||||
|
||||
merge = (scenarios: RouterScenario[]) =>
|
||||
this.scenario(scenarios.map(scenario => scenario.policy(this.addNoFallbacks).getUrls()))
|
||||
|
||||
// Routing scenarios
|
||||
|
||||
User = () => this.scenario([this.getUserRelays()])
|
||||
|
||||
ReadRelays = () => this.scenario([this.getUserRelays()]).mode(RelayMode.Read)
|
||||
|
||||
WriteRelays = () => this.scenario([this.getUserRelays()]).mode(RelayMode.Write)
|
||||
|
||||
AllMessages = () => this.scenario([this.getUserRelays()])
|
||||
|
||||
Messages = (pubkeys: string[]) =>
|
||||
this.scenario([
|
||||
this.getUserRelays(),
|
||||
...pubkeys.map(pubkey => this.options.getPubkeyRelays(pubkey))
|
||||
])
|
||||
|
||||
PublishMessage = (pubkey: string) =>
|
||||
this.scenario([
|
||||
this.getUserRelays(RelayMode.Write),
|
||||
this.options.getPubkeyRelays(pubkey, RelayMode.Read)
|
||||
]).policy(this.addMinimalFallbacks)
|
||||
|
||||
Event = (event: UnsignedEvent) =>
|
||||
this.scenario([
|
||||
this.options.getPubkeyRelays(event.pubkey, RelayMode.Write),
|
||||
...this.getContextRelayGroups(event),
|
||||
])
|
||||
|
||||
EventChildren = (event: UnsignedEvent) =>
|
||||
this.scenario([
|
||||
this.options.getPubkeyRelays(event.pubkey, RelayMode.Read),
|
||||
...this.getContextRelayGroups(event),
|
||||
])
|
||||
|
||||
EventParent = (event: UnsignedEvent) => {
|
||||
const tags = Tags.fromEvent(event)
|
||||
|
||||
return this.scenario([
|
||||
tags.replies().relays().valueOf(),
|
||||
tags.roots().relays().valueOf(),
|
||||
...this.getContextRelayGroups(event),
|
||||
...tags.whereKey("p").values().valueOf()
|
||||
.map(pk => this.options.getPubkeyRelays(pk, RelayMode.Write)),
|
||||
tags.whereKey("p").relays().valueOf(),
|
||||
this.options.getPubkeyRelays(event.pubkey, RelayMode.Read),
|
||||
])
|
||||
}
|
||||
|
||||
EventRoot = (event: UnsignedEvent) => {
|
||||
const tags = Tags.fromEvent(event)
|
||||
|
||||
return this.scenario([
|
||||
tags.roots().relays().valueOf(),
|
||||
tags.replies().relays().valueOf(),
|
||||
...this.getContextRelayGroups(event),
|
||||
...tags.whereKey("p").values().valueOf()
|
||||
.map(pk => this.options.getPubkeyRelays(pk, RelayMode.Write)),
|
||||
tags.whereKey("p").relays().valueOf(),
|
||||
this.options.getPubkeyRelays(event.pubkey, RelayMode.Read),
|
||||
])
|
||||
}
|
||||
|
||||
PublishEvent = (event: UnsignedEvent) => {
|
||||
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 this.scenario(groupAddresses.map(this.options.getGroupRelays))
|
||||
}
|
||||
|
||||
return this.scenario([
|
||||
this.options.getPubkeyRelays(event.pubkey, RelayMode.Write),
|
||||
...groupAddresses.map(this.options.getGroupRelays),
|
||||
...communityAddresses.map(this.options.getCommunityRelays),
|
||||
...mentions.map((pk: string) => this.options.getPubkeyRelays(pk, RelayMode.Read)),
|
||||
])
|
||||
}
|
||||
|
||||
FromPubkeys = (pubkeys: string[]) =>
|
||||
this.scenario(pubkeys.map(pk => this.options.getPubkeyRelays(pk, RelayMode.Write)))
|
||||
|
||||
ForPubkeys = (pubkeys: string[]) =>
|
||||
this.scenario(pubkeys.map(pk => this.options.getPubkeyRelays(pk, RelayMode.Read)))
|
||||
|
||||
WithinGroup = (address: string) =>
|
||||
this.scenario([this.options.getGroupRelays(address)]).policy(this.addNoFallbacks)
|
||||
|
||||
WithinCommunity = (address: string) =>
|
||||
this.scenario([this.options.getCommunityRelays(address)])
|
||||
|
||||
WithinContext = (address: string) => {
|
||||
if (isGroupAddress(address)) {
|
||||
return this.WithinGroup(address)
|
||||
}
|
||||
|
||||
if (isCommunityAddress(address)) {
|
||||
return this.WithinCommunity(address)
|
||||
}
|
||||
|
||||
throw new Error(`Unknown context ${address}`)
|
||||
}
|
||||
|
||||
WithinMultipleContexts = (addresses: string[]) =>
|
||||
this.merge(addresses.map(this.WithinContext))
|
||||
|
||||
// Fallback policies
|
||||
|
||||
addNoFallbacks = (urls: string[], limit: number) => 0
|
||||
|
||||
addMinimalFallbacks = (urls: string[], limit: number) => Math.max(0, 1 - urls.length)
|
||||
|
||||
addMaximalFallbacks = (urls: string[], limit: number) => Math.max(0, limit - urls.length)
|
||||
|
||||
// Higher level utils that use hints
|
||||
|
||||
tagPubkey = (pubkey: string) =>
|
||||
Tag.from(["p", pubkey, this.FromPubkeys([pubkey]).getUrl()])
|
||||
|
||||
tagEventId = (event: Rumor, ...extra: string[]) =>
|
||||
Tag.from(["e", event.id, this.Event(event).getUrl(), ...extra])
|
||||
|
||||
tagEventAddress = (event: UnsignedEvent, ...extra: string[]) =>
|
||||
Tag.from(["a", getAddress(event), this.Event(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)
|
||||
}
|
||||
|
||||
address = (event: UnsignedEvent) =>
|
||||
addressFromEvent(event, this.Event(event).limit(3).getUrls())
|
||||
}
|
||||
|
||||
// Router Scenario
|
||||
|
||||
export type RouterScenarioOptions = {
|
||||
mode?: RelayMode
|
||||
limit?: number
|
||||
policy?: FallbackPolicy
|
||||
}
|
||||
|
||||
export class RouterScenario {
|
||||
constructor(readonly router: Router, readonly groups: string[][], readonly options: RouterScenarioOptions = {}) {}
|
||||
|
||||
clone = (options: RouterScenarioOptions) =>
|
||||
new RouterScenario(this.router, this.groups, {...this.options, ...options})
|
||||
|
||||
limit = (limit: number) => this.clone({limit})
|
||||
|
||||
mode = (mode: RelayMode) => this.clone({mode})
|
||||
|
||||
policy = (policy: FallbackPolicy) => this.clone({policy})
|
||||
|
||||
getLimit = () => this.options.limit || this.router.options.getDefaultLimit()
|
||||
|
||||
getPolicy = () => this.options.policy || this.router.addMaximalFallbacks
|
||||
|
||||
getFallbackRelays = () =>
|
||||
shuffle(this.router.options.getDefaultRelays(this.options.mode))
|
||||
|
||||
getUrls = () => {
|
||||
const fallbackPolicy = this.getPolicy()
|
||||
const urls = this.router.scoreGroups(this.groups).map(s => s.url)
|
||||
const limit = this.getLimit()
|
||||
const limitWithFallbacks = Math.min(limit, urls.length) + fallbackPolicy(urls, limit)
|
||||
|
||||
for (const url of this.getFallbackRelays()) {
|
||||
if (urls.length >= limitWithFallbacks) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!urls.includes(url)) {
|
||||
urls.push(url)
|
||||
}
|
||||
}
|
||||
|
||||
return urls.slice(0, limitWithFallbacks)
|
||||
}
|
||||
|
||||
getUrl = () => first(this.limit(1).getUrls())
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import {EventTemplate} from 'nostr-tools'
|
||||
import type {OmitStatics} from '@coracle.social/lib'
|
||||
import {Fluent, last} from '@coracle.social/lib'
|
||||
import {isShareableRelayUrl, normalizeRelayUrl} from './Relays'
|
||||
import type {Address} from './Address'
|
||||
import {encodeAddress, decodeAddress} from './Address'
|
||||
import {GROUP_DEFINITION, COMMUNITY_DEFINITION} from './Kinds'
|
||||
|
||||
export class Tag extends (Fluent<string> as OmitStatics<typeof Fluent<string>, 'from'>) {
|
||||
static from(xs: Iterable<string>) {
|
||||
return new Tag(Array.from(xs))
|
||||
}
|
||||
|
||||
static fromAddress = (a: Address) => new Tag(["a", encodeAddress(a), a.relays[0] || ""])
|
||||
|
||||
valueOf = () => this.xs
|
||||
|
||||
key = () => this.xs[0]
|
||||
|
||||
value = () => this.xs[1]
|
||||
|
||||
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)
|
||||
|
||||
asAddress = () => decodeAddress(this.value())
|
||||
|
||||
isAddress = (kind?: number) => this.key() === "a" && this.value()?.startsWith(`${kind}:`)
|
||||
|
||||
isGroup = () => this.isAddress(GROUP_DEFINITION)
|
||||
|
||||
isCommunity = () => this.isAddress(COMMUNITY_DEFINITION)
|
||||
|
||||
isContext = () => this.isAddress(GROUP_DEFINITION) || this.isAddress(COMMUNITY_DEFINITION)
|
||||
}
|
||||
|
||||
export class Tags extends (Fluent<Tag> as OmitStatics<typeof Fluent<Tag>, 'from'>) {
|
||||
static from(p: Iterable<string[]>) {
|
||||
return new Tags(Array.from(p).map(Tag.from))
|
||||
}
|
||||
|
||||
static fromEvent(event: EventTemplate) {
|
||||
return Tags.from(event.tags || [])
|
||||
}
|
||||
|
||||
static fromEvents(events: EventTemplate[]) {
|
||||
return Tags.from(events.flatMap(e => e.tags || []))
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
valueOf = () => this.xs.map(tag => tag.valueOf())
|
||||
|
||||
whereKey = (key: string) => this.filter(t => t.key() === key)
|
||||
|
||||
whereValue = (value: string) => this.filter(t => t.value() === value)
|
||||
|
||||
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 = (key?: string) => (key ? this.whereKey(key) : this).mapTo(t => t.value())
|
||||
|
||||
marks = () => this.mapTo(t => t.mark())
|
||||
|
||||
entries = () => this.mapTo(t => t.entry())
|
||||
|
||||
relays = () => this.flatMap((t: Tag) => t.valueOf().filter(isShareableRelayUrl).map(normalizeRelayUrl)).uniq()
|
||||
|
||||
topics = () => this.whereKey("t").values().map((t: string) => t.replace(/^#/, ""))
|
||||
|
||||
ancestors = () => {
|
||||
const tags = this.filter(t => ["a", "e", "q"].includes(t.key()) && !t.isContext())
|
||||
const parentTags = tags.filter(t => ["a", "e"].includes(t.key()))
|
||||
const mentionTags = tags.whereKey("q")
|
||||
const roots: string[][] = []
|
||||
const replies: string[][] = []
|
||||
const mentions: string[][] = []
|
||||
|
||||
parentTags
|
||||
.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 === parentTags.count() - 1) {
|
||||
replies.push(t.valueOf())
|
||||
} else {
|
||||
mentions.push(t.valueOf())
|
||||
}
|
||||
})
|
||||
|
||||
// Add quotes as mentions separately so positional logic above works
|
||||
mentionTags.forEach((t: Tag) => mentions.push(t.valueOf()))
|
||||
|
||||
return {
|
||||
roots: Tags.from(roots),
|
||||
replies: Tags.from(replies),
|
||||
mentions: Tags.from(mentions),
|
||||
}
|
||||
}
|
||||
|
||||
roots = () => this.ancestors().roots
|
||||
|
||||
replies = () => this.ancestors().replies
|
||||
|
||||
mentions = () => this.ancestors().mentions
|
||||
|
||||
root = () => {
|
||||
const roots = this.roots()
|
||||
|
||||
return roots.get("e") || roots.get("a")
|
||||
}
|
||||
|
||||
reply = () => {
|
||||
const replies = this.replies()
|
||||
|
||||
return replies.get("e") || replies.get("a")
|
||||
}
|
||||
|
||||
parents = () => {
|
||||
const {roots, replies} = this.ancestors()
|
||||
|
||||
return replies.exists() ? replies: roots
|
||||
}
|
||||
|
||||
parent = () => {
|
||||
const parents = this.parents()
|
||||
|
||||
return parents.get("e") || parents.get("a")
|
||||
}
|
||||
|
||||
groups = () => this.whereKey("a").filter(t => t.isGroup())
|
||||
|
||||
communities = () => this.whereKey("a").filter(t => t.isCommunity())
|
||||
|
||||
context = () => this.whereKey("a").filter(t => t.isContext())
|
||||
|
||||
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.isContext())
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './Address'
|
||||
export * from './Events'
|
||||
export * from './Filters'
|
||||
export * from './Kinds'
|
||||
export * from './Links'
|
||||
export * from './Relays'
|
||||
export * from './Router'
|
||||
export * from './Tags'
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@coracle.social/util",
|
||||
"version": "0.0.2",
|
||||
"author": "hodlbod",
|
||||
"license": "MIT",
|
||||
"description": "A collection of utilities.",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"build"
|
||||
],
|
||||
"types": "./build/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./build/index.d.ts",
|
||||
"import": "./build/index.mjs",
|
||||
"require": "./build/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"pub": "npm run lint && npm run rebuild && npm publish",
|
||||
"rebuild": "npm run clean && npm run build",
|
||||
"build": "tsc-multi",
|
||||
"clean": "gts clean",
|
||||
"lint": "gts lint",
|
||||
"fix": "gts fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/events": "^3.0.3",
|
||||
"gts": "^5.0.1",
|
||||
"tsc-multi": "^1.1.0",
|
||||
"typescript": "~5.1.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coracle.social/lib": "^0.0.1",
|
||||
"nostr-tools": "^2.3.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"targets": [
|
||||
{"extname": ".cjs", "module": "commonjs"},
|
||||
{"extname": ".mjs", "module": "esnext", "moduleResolution": "node"}
|
||||
],
|
||||
"projects": ["tsconfig.json"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../node_modules/gts/tsconfig-google.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "build",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["es2019"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user