Put everything in src directories

This commit is contained in:
Jon Staab
2024-06-12 09:34:45 -07:00
parent f2f16bc3d3
commit 39ca2fe6aa
48 changed files with 22 additions and 22 deletions
+67
View File
@@ -0,0 +1,67 @@
import {nip19} from 'nostr-tools'
import {GROUP, COMMUNITY} from './Kinds'
// Define this locally to avoid circular dependencies
type AddressableEvent = {
kind: number
pubkey: string
tags: string[][]
}
export class Address {
constructor(
readonly kind: number,
readonly pubkey: string,
readonly identifier: string,
readonly relays: string[] = []
) {}
static isAddress(address: string) {
return Boolean(address.match(/^\d+:\w+:.*$/))
}
static from(address: string, relays: string[] = []) {
const [kind, pubkey, identifier = ""] = address.match(/^(\d+):(\w+):(.*)$/)!.slice(1)
return new Address(parseInt(kind), pubkey, identifier, relays)
}
static fromNaddr(naddr: string) {
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 new Address(data.kind, data.pubkey, data.identifier, data.relays)
}
static fromEvent(event: AddressableEvent, relays: string[] = []) {
const identifier = event.tags.find(t => t[0] === "d")?.[1] || ""
return new Address(event.kind, event.pubkey, identifier, relays)
}
toString = () => [this.kind, this.pubkey, this.identifier].join(":")
toNaddr = () => nip19.naddrEncode(this)
}
// Utils
export const getAddress = (e: AddressableEvent) => Address.fromEvent(e).toString()
export const isGroupAddress = (a: string, ...args: unknown[]) => Address.from(a).kind === GROUP
export const isCommunityAddress = (a: string, ...args: unknown[]) => Address.from(a).kind === COMMUNITY
export const isContextAddress = (a: string, ...args: unknown[]) => [GROUP, COMMUNITY].includes(Address.from(a).kind)
+118
View File
@@ -0,0 +1,118 @@
import {verifiedSymbol} from 'nostr-tools'
import {verifyEvent, getEventHash} from 'nostr-tools'
import {cached, pick, now} from '@welshman/lib'
import {Tags} from './Tags'
import {getAddress} from './Address'
import {isEphemeralKind, isReplaceableKind, isPlainReplaceableKind, isParameterizedReplaceableKind} from './Kinds'
export type EventTemplate = {
kind: number
tags: string[][]
content: string
created_at: number
}
export type OwnedEvent = EventTemplate & {
pubkey: string
}
export type HashedEvent = OwnedEvent & {
id: string
}
export type SignedEvent = HashedEvent & {
sig: string
[verifiedSymbol]?: boolean
}
export type UnwrappedEvent = HashedEvent & {
wrap: SignedEvent
}
export type TrustedEvent = HashedEvent & {
sig?: string
wrap?: SignedEvent
[verifiedSymbol]?: boolean
}
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 isEventTemplate = (e: EventTemplate): e is EventTemplate =>
Boolean(typeof e.kind === "number" && e.tags && typeof e.content === "string" && e.created_at)
export const isOwnedEvent = (e: OwnedEvent): e is OwnedEvent =>
Boolean(isEventTemplate(e) && e.pubkey)
export const isHashedEvent = (e: HashedEvent): e is HashedEvent =>
Boolean(isOwnedEvent(e) && e.id)
export const isSignedEvent = (e: TrustedEvent): e is SignedEvent =>
Boolean(isHashedEvent(e) && e.sig)
export const isUnwrappedEvent = (e: TrustedEvent): e is UnwrappedEvent =>
Boolean(isHashedEvent(e) && e.wrap)
export const isTrustedEvent = (e: TrustedEvent): e is TrustedEvent =>
isSignedEvent(e) || isUnwrappedEvent(e)
export const asEventTemplate = (e: EventTemplate): EventTemplate =>
pick(['kind', 'tags', 'content', 'created_at'], e)
export const asOwnedEvent = (e: OwnedEvent): OwnedEvent =>
pick(['kind', 'tags', 'content', 'created_at', 'pubkey'], e)
export const asHashedEvent = (e: HashedEvent): HashedEvent =>
pick(['kind', 'tags', 'content', 'created_at', 'pubkey', 'id'], e)
export const asSignedEvent = (e: SignedEvent): SignedEvent =>
pick(['kind', 'tags', 'content', 'created_at', 'pubkey', 'id', 'sig'], e)
export const asUnwrappedEvent = (e: UnwrappedEvent): UnwrappedEvent =>
pick(['kind', 'tags', 'content', 'created_at', 'pubkey', 'id', 'wrap'], e)
export const asTrustedEvent = (e: TrustedEvent): TrustedEvent =>
pick(['kind', 'tags', 'content', 'created_at', 'pubkey', 'id', 'sig', 'wrap'], e)
export const hasValidSignature = cached<string, boolean, [SignedEvent]>({
maxSize: 10000,
getKey: ([e]: [SignedEvent]) => {
try {
return [getEventHash(e), e.sig].join(":")
} catch (err) {
return 'invalid'
}
},
getValue: ([e]: [SignedEvent]) => {
try {
return verifyEvent(e)
} catch (err) {
return false
}
},
})
export const getIdOrAddress = (e: HashedEvent) => isReplaceable(e) ? getAddress(e) : e.id
export const getIdAndAddress = (e: HashedEvent) => 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: HashedEvent) => {
const {roots, replies} = Tags.fromEvent(child).ancestors()
const parentIds = (replies.exists() ? replies : roots).values().valueOf()
return getIdAndAddress(parent).some(x => parentIds.includes(x))
}
+221
View File
@@ -0,0 +1,221 @@
import {Event} from 'nostr-tools'
import {matchFilter as nostrToolsMatchFilter} from 'nostr-tools'
import {uniqBy, prop, mapVals, shuffle, avg, hash, groupBy, randomId, uniq} from '@welshman/lib'
import type {HashedEvent, TrustedEvent} from './Events'
import {isReplaceableKind} from './Kinds'
import {Address, getAddress} from './Address'
export const EPOCH = 1609459200
export type Filter = {
ids?: string[]
kinds?: number[]
authors?: string[]
since?: number
until?: number
limit?: number
search?: string
[key: `#${string}`]: string[]
}
export const matchFilter = <E extends HashedEvent>(filter: Filter, event: E) => {
if (!nostrToolsMatchFilter(filter, event as unknown as 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 = <E extends HashedEvent>(filters: Filter[], event: E) => {
for (const filter of filters) {
if (matchFilter(filter, event)) {
return true
}
}
return false
}
export const getFilterId = (filter: Filter) => {
const keys = Object.keys(filter)
keys.sort()
const parts = []
for (const k of keys) {
const v = filter[k as keyof Filter]
const s = Array.isArray(v) ? v.join(',') : v
parts.push([k, s].join(':'))
}
return hash(parts.join('|'))
}
export const calculateFilterGroup = ({since, until, limit, search, ...filter}: Filter) => {
const group = Object.keys(filter)
if (since) group.push(`since:${since}`)
if (until) group.push(`until:${until}`)
if (limit) group.push(`limit:${randomId()}`)
if (search) group.push(`search:${search}`)
return group.sort().join("-")
}
export const unionFilters = (filters: Filter[]) => {
const result = []
// Group, but also get unique filters by ids because duplicates can come through subscribe
for (const group of groupBy(calculateFilterGroup, uniqBy(getFilterId, filters)).values()) {
const newFilter: Record<string, any> = {}
for (const k of Object.keys(group[0])) {
if (["since", "until", "limit", "search"].includes(k)) {
newFilter[k] = (group[0] as Record<string, any>)[k]
} else {
newFilter[k] = uniq(group.flatMap(prop(k)))
}
}
result.push(newFilter as Filter)
}
return result
}
export const intersectFilters = (groups: Filter[][]) => {
let result = groups[0]
for (const filters of groups.slice(1)) {
result = result.flatMap(f1 => {
return filters.map(f2 => {
const f3: Filter = {}
for (const k of uniq([...Object.keys(f1), ...Object.keys(f2)]) as (keyof Filter)[]) {
if (k === 'since' || k === 'limit') {
f3[k] = Math.max(f1[k] || 0, f2[k] || 0)
} else if (k === 'until') {
f3[k] = Math.min(f1[k] || f2[k] || 0, f2[k] || f1[k] || 0)
} else if (k === 'search') {
if (f1[k] && f2[k] && f1[k] !== f2[k]) {
f3[k] = [f1[k], f2[k]].join(' ')
} else {
f3[k] = f1[k] || f2[k]
}
} else {
f3[k] = uniq([...(f1[k] || []), ...(f2[k] || [])]) as any[]
}
}
return f3
})
})
}
return unionFilters(result)
}
export const getIdFilters = (idsOrAddresses: string[]) => {
const ids = []
const aFilters = []
for (const idOrAddress of idsOrAddresses) {
if (Address.isAddress(idOrAddress)) {
const {kind, pubkey, identifier} = Address.from(idOrAddress)
if (identifier) {
aFilters.push({kinds: [kind], authors: [pubkey], "#d": [identifier]})
} else {
aFilters.push({kinds: [kind], authors: [pubkey]})
}
} else {
ids.push(idOrAddress)
}
}
const filters = unionFilters(aFilters)
if (ids.length > 0) {
filters.push({ids})
}
return filters
}
export const getReplyFilters = (events: TrustedEvent[], filter: Filter) => {
const a = []
const e = []
for (const event of events) {
e.push(event.id)
if (isReplaceableKind(event.kind)) {
a.push(getAddress(event))
}
if (event.wrap) {
e.push(event.wrap.id)
}
}
const filters = []
if (a.length > 0) {
filters.push({...filter, "#a": a})
}
if (e.length > 0) {
filters.push({...filter, "#e": e})
}
return filters
}
export const getFilterGenerality = (filter: Filter) => {
if (filter.ids || filter["#e"] || filter["#a"]) {
return 0
}
const hasTags = Object.keys(filter).find((k: string) => k.startsWith("#"))
if (filter.authors && hasTags) {
return 0.2
}
if (filter.authors) {
return Math.min(1, filter.authors.length / 100)
}
return 1
}
export const guessFilterDelta = (filters: Filter[], max = 60 * 60 * 24 * 7) =>
Math.round(max * Math.max(0.005, 1 - avg(filters.map(getFilterGenerality))))
// If a filter is specifying ids, we know how many results to expect
export const getFilterResultCardinality = (filter: Filter) => {
if (filter.ids) {
return filter.ids.length
}
return null
}
export const trimFilter = (filter: Filter) =>
mapVals(v => Array.isArray(v) && v.length > 1000 ? shuffle(v).slice(0, 1000) : v, filter)
export const trimFilters = (filters: Filter[]) => filters.map(trimFilter)
+149
View File
@@ -0,0 +1,149 @@
import {kinds} from 'nostr-tools'
export const isRegularKind = kinds.isRegularKind
export const isEphemeralKind = kinds.isEphemeralKind
export const isPlainReplaceableKind = kinds.isReplaceableKind
export const isParameterizedReplaceableKind = kinds.isParameterizedReplaceableKind
export const isReplaceableKind = (kind: number) =>
isPlainReplaceableKind(kind) || isParameterizedReplaceableKind(kind)
export const PROFILE = 0
export const NOTE = 1
export const FOLLOWS = 3
export const DELETE = 5
export const REPOST = 6
export const REACTION = 7
export const BADGE_AWARD = 8
export const GROUP_CHAT_MESSAGE = 9
export const GROUP_CHAT_REPLY = 10
export const GROUP_CHAT_THREAD = 11
export const GROUP_CHAT_THREAD_REPLY = 12
export const SEAL = 13
export const DIRECT_MESSAGE = 14
export const READ_RECEIPT = 15
export const GENERIC_REPOST = 16
export const CHANNEL_CREATE = 40
export const CHANNEL_UPDATE = 41
export const CHANNEL_MESSAGE = 42
export const CHANNEL_HIDE_MESSAGE = 43
export const CHANNEL_MUTE_USER = 44
export const BID = 1021
export const BID_CONFIRMATION = 1022
export const OTS = 1040
export const WRAP = 1059
export const WRAP_NIP04 = 1060
export const FILE_METADATA = 1063
export const LIVE_CHAT_MESSAGE = 1311
export const GIT_PATCH = 1617
export const GIT_ISSUE = 1617
export const GIT_REPLY = 1617
export const GIT_STATUS_OPEN = 1630
export const GIT_STATUS_COMPLETE = 1631
export const GIT_STATUS_CLOSED = 1632
export const GIT_STATUS_DRAFT = 1633
export const GIT_REPOSITORY = 30403
export const REMIX = 1808
export const NOSTROCKER_PROBLEM = 1971
export const REPORT = 1984
export const LABEL = 1985
export const REVIEW = 1986
export const APPROVAL = 4550
export const DVM_REQUEST_TEXT_EXTRACTION = 5000
export const DVM_REQUEST_TEXT_SUMMARY = 5001
export const DVM_REQUEST_TEXT_TRANSLATION = 5002
export const DVM_REQUEST_TEXT_GENERATION = 5050
export const DVM_REQUEST_IMAGE_GENERATION = 5100
export const DVM_REQUEST_VIDEO_CONVERSION = 5200
export const DVM_REQUEST_VIDEO_TRANSLATION = 5201
export const DVM_REQUEST_IMAGE_TO_VIDEO_CONVERSION = 5202
export const DVM_REQUEST_TEXT_TO_SPEECH = 5250
export const DVM_REQUEST_DISCOVER_CONTENT = 5300
export const DVM_REQUEST_DISCOVER_PEOPLE = 5301
export const DVM_REQUEST_SEARCH_CONTENT = 5302
export const DVM_REQUEST_SEARCH_PEOPLE = 5303
export const DVM_REQUEST_COUNT = 5400
export const DVM_REQUEST_MALWARE_SCAN = 5500
export const DVM_REQUEST_OTS = 5900
export const DVM_REQUEST_OP_RETURN = 5901
export const DVM_REQUEST_PUBLISH_SCHEDULE = 5905
export const DVM_RESPONSE_TEXT_EXTRACTION = 6000
export const DVM_RESPONSE_TEXT_SUMMARY = 6001
export const DVM_RESPONSE_TEXT_TRANSLATION = 6002
export const DVM_RESPONSE_TEXT_GENERATION = 6050
export const DVM_RESPONSE_IMAGE_GENERATION = 6100
export const DVM_RESPONSE_VIDEO_CONVERSION = 6200
export const DVM_RESPONSE_VIDEO_TRANSLATION = 6201
export const DVM_RESPONSE_IMAGE_TO_VIDEO_CONVERSION = 6202
export const DVM_RESPONSE_TEXT_TO_SPEECH = 6250
export const DVM_RESPONSE_DISCOVER_CONTENT = 6300
export const DVM_RESPONSE_DISCOVER_PEOPLE = 6301
export const DVM_RESPONSE_SEARCH_CONTENT = 6302
export const DVM_RESPONSE_SEARCH_PEOPLE = 6303
export const DVM_RESPONSE_COUNT = 6400
export const DVM_RESPONSE_MALWARE_SCAN = 6500
export const DVM_RESPONSE_OTS = 6900
export const DVM_RESPONSE_OP_RETURN = 6901
export const DVM_RESPONSE_PUBLISH_SCHEDULE = 6905
export const DVM_FEEDBACK = 7000
export const ZAP_GOAL = 9041
export const ZAP_REQUEST = 9734
export const ZAP_RESPONSE = 9735
export const HIGHLIGHT = 9802
export const MUTES = 10000
export const PINS = 10001
export const RELAYS = 10002
export const BOOKMARKS = 10003
export const COMMUNITIES = 10004
export const CHANNELS = 10005
export const BLOCKED_RELAYS = 10006
export const SEARCH_RELAYS = 10007
export const GROUPS = 10009
export const TOPICS = 10015
export const EMOJIS = 10030
export const DM_INBOX_RELAYS = 10050
export const FILE_SERVERS = 10096
export const LIGHTNING_PUB_RPC = 21000
export const CLIENT_AUTH = 22242
export const WALLET_INFO = 13194
export const WALLET_REQUEST = 23194
export const WALLET_RESPONSE = 23195
export const NOSTR_CONNECT = 24133
export const HTTP_AUTH = 27235
export const NAMED_PEOPLE = 30000
export const NAMED_RELAYS = 30002
export const NAMED_BOOKMARKS = 30003
export const NAMED_CURATIONS = 30004
export const NAMED_WIKI_AUTHORS = 30101
export const NAMED_WIKI_RELAYS = 30102
export const NAMED_EMOJIS = 30030
export const NAMED_TOPICS = 30015
export const NAMED_ARTIFACTS = 30063
export const BADGES = 30008
export const BADGE_DEFINITION = 30009
export const STALL = 30017
export const PRODUCT = 30018
export const MARKET_UI = 30019
export const PRODUCT_SOLD_AS_AUCTION = 30020
export const WIKI = 30818
export const LONG_FORM = 30023
export const LONG_FORM_DRAFT = 30024
export const APP_DATA = 30078
export const LIVE_EVENT = 30311
export const STATUS = 30315
export const CLASSIFIED = 30402
export const DRAFT_CLASSIFIED = 30403
export const AUDIO = 31337
export const FEED = 31890
export const CALENDAR = 31924
export const EVENT_DATE = 31922
export const EVENT_TIME = 31923
export const EVENT_RSVP = 31925
export const HANDLER_RECOMMENDATION = 31989
export const HANDLER_INFORMATION = 31990
export const COMMUNITY = 34550
export const GROUP = 35834
export const DEPRECATED_RELAY_RECOMMENDATION = 2
export const DEPRECATED_DIRECT_MESSAGE = 4
export const DEPRECATED_NAMED_GENERIC = 30001
+3
View File
@@ -0,0 +1,3 @@
export const fromNostrURI = (s: string) => s.replace(/^nostr:\/?\/?/, "")
export const toNostrURI = (s: string) => `nostr:${s}`
+99
View File
@@ -0,0 +1,99 @@
import {Emitter, normalizeUrl, sleep, stripProtocol} from '@welshman/lib'
import {matchFilters} from './Filters'
import type {Repository} from './Repository'
import type {Filter} from './Filters'
import type {TrustedEvent} from './Events'
export const LOCAL_RELAY_URL = "local://welshman.relay"
export const BOGUS_RELAY_URL = "bogus://welshman.relay"
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 stuff with a numeric tld
!url.slice(6).match(/\.\d+\b/) &&
// 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/)
)
type NormalizeRelayUrlOpts = {
allowInsecure?: boolean
}
export const normalizeRelayUrl = (url: string, {allowInsecure = false}: NormalizeRelayUrlOpts = {}) => {
const prefix = allowInsecure ? url.match(/^wss?:\/\//)?.[0] || "wss://" : "wss://"
// Use our library to normalize
url = normalizeUrl(url, {stripHash: true, stripAuthentication: false})
// Strip the protocol since only wss works, lowercase
url = stripProtocol(url).toLowerCase()
// Urls without pathnames are supposed to have a trailing slash
if (!url.includes("/")) {
url += "/"
}
return prefix + url
}
export class Relay extends Emitter {
subs = new Map<string, Filter[]>()
constructor(readonly repository: Repository) {
super()
}
send(type: string, ...message: any[]) {
switch(type) {
case 'EVENT': return this.handleEVENT(message as [TrustedEvent])
case 'CLOSE': return this.handleCLOSE(message as [string])
case 'REQ': return this.handleREQ(message as [string, ...Filter[]])
}
}
handleEVENT([event]: [TrustedEvent]) {
this.repository.publish(event)
// Callers generally expect async relays
sleep(1).then(() => {
this.emit('OK', event.id, true, "")
if (!this.repository.isDeleted(event)) {
for (const [subId, filters] of this.subs.entries()) {
if (matchFilters(filters, event)) {
this.emit('EVENT', subId, event)
}
}
}
})
}
handleCLOSE([subId]: [string]) {
this.subs.delete(subId)
}
handleREQ([subId, ...filters]: [string, ...Filter[]]) {
this.subs.set(subId, filters)
// Callers generally expect async relays
sleep(1).then(() => {
for (const event of this.repository.query(filters)) {
this.emit('EVENT', subId, event)
}
this.emit('EOSE', subId)
})
}
}
+223
View File
@@ -0,0 +1,223 @@
import {flatten, Emitter, sortBy, inc, chunk, sleep, uniq, omit, now, range, identity} from '@welshman/lib'
import {DELETE} from './Kinds'
import {EPOCH, matchFilter} from './Filters'
import {isReplaceable, isTrustedEvent} from './Events'
import {getAddress} from './Address'
import type {Filter} from './Filters'
import type {TrustedEvent} from './Events'
export const DAY = 86400
const getDay = (ts: number) => Math.floor(ts / DAY)
export class Repository extends Emitter {
eventsById = new Map<string, TrustedEvent>()
eventsByWrap = new Map<string, TrustedEvent>()
eventsByAddress = new Map<string, TrustedEvent>()
eventsByTag = new Map<string, TrustedEvent[]>()
eventsByDay = new Map<number, TrustedEvent[]>()
eventsByAuthor = new Map<string, TrustedEvent[]>()
deletes = new Map<string, number>()
// Dump/load/clear
dump = () => {
return Array.from(this.eventsById.values())
}
load = async (events: TrustedEvent[], chunkSize = 1000) => {
this.clear()
for (const eventsChunk of chunk(chunkSize, events)) {
for (const event of eventsChunk) {
this.publish(event, {shouldNotify: false})
}
if (eventsChunk.length === chunkSize) {
await sleep(1)
}
}
this.emit('update', {
added: events,
removed: new Set(this.deletes.keys()),
})
}
clear = () => {
this.eventsById.clear()
this.eventsByWrap.clear()
this.eventsByAddress.clear()
this.eventsByTag.clear()
this.eventsByDay.clear()
this.eventsByAuthor.clear()
this.deletes.clear()
}
// API
getEvent = (idOrAddress: string) => {
return idOrAddress.includes(':')
? this.eventsByAddress.get(idOrAddress)
: this.eventsById.get(idOrAddress)
}
hasEvent = (event: TrustedEvent) => {
const duplicate = (
this.eventsById.get(event.id) ||
this.eventsByAddress.get(getAddress(event))
)
return duplicate && duplicate.created_at >= event.created_at
}
query = (filters: Filter[], {includeDeleted = false} = {}) => {
const result: TrustedEvent[][] = []
for (let filter of filters) {
let events: TrustedEvent[] = Array.from(this.eventsById.values())
if (filter.ids) {
events = filter.ids!.map(id => this.eventsById.get(id)).filter(identity) as TrustedEvent[]
filter = omit(['ids'], filter)
} else if (filter.authors) {
events = uniq(filter.authors!.flatMap(pubkey => this.eventsByAuthor.get(pubkey) || []))
filter = omit(['authors'], filter)
} else if (filter.since || filter.until) {
const sinceDay = getDay(filter.since || EPOCH)
const untilDay = getDay(filter.until || now())
events = uniq(
Array.from(range(sinceDay, inc(untilDay)))
.flatMap((day: number) => this.eventsByDay.get(day) || [])
)
} else {
for (const [k, values] of Object.entries(filter)) {
if (!k.startsWith('#') || k.length !== 2) {
continue
}
filter = omit([k], filter)
events = uniq(
(values as string[]).flatMap(v => this.eventsByTag.get(`${k[1]}:${v}`) || [])
)
break
}
}
const chunk: TrustedEvent[] = []
for (const event of sortBy((e: TrustedEvent) => -e.created_at, events)) {
if (filter.limit && chunk.length >= filter.limit) {
break
}
if (!includeDeleted && this.isDeleted(event)) {
continue
}
if (matchFilter(filter, event)) {
chunk.push(event)
}
}
result.push(chunk)
}
return uniq(flatten(result))
}
publish = (event: TrustedEvent, {shouldNotify = true} = {}) => {
if (!isTrustedEvent(event)) {
throw new Error("Invalid event published to Repository", event)
}
const address = getAddress(event)
const duplicate = (
this.eventsById.get(event.id) ||
this.eventsByAddress.get(address)
)
// If our duplicate is newer than the event we're adding, we're done
if (duplicate && duplicate.created_at >= event.created_at) {
this.deletes.set(event.id, duplicate.created_at)
return
}
// Delete our duplicate
if (duplicate) {
this.deletes.set(duplicate.id, event.created_at)
}
// Add our new event by id
this.eventsById.set(event.id, event)
// Add our new event by address
if (isReplaceable(event)) {
this.eventsByAddress.set(address, event)
}
// Save wrapper index
if (event.wrap) {
this.eventsByWrap.set(event.wrap.id, event)
}
// Update our timestamp and author indexes
this._updateIndex(this.eventsByDay, getDay(event.created_at), event, duplicate)
this._updateIndex(this.eventsByAuthor, event.pubkey, event, duplicate)
// Keep track of deleted events to notify about
const removed = new Set<string>()
// Update our tag indexes
for (const tag of event.tags) {
if (tag[0].length === 1) {
this._updateIndex(this.eventsByTag, tag.slice(0, 2).join(':'), event, duplicate)
// If this is a delete event, the tag value is an id or address. Track when it was
// deleted so that replaceables can be restored.
if (event.kind === DELETE) {
this.deletes.set(tag[1], Math.max(event.created_at, this.deletes.get(tag[1]) || 0))
const deletedEvent = this.getEvent(tag[1])
if (deletedEvent && this.isDeleted(deletedEvent)) {
removed.add(deletedEvent.id)
}
}
}
}
if (duplicate) {
removed.add(duplicate.id)
}
if (shouldNotify) {
this.emit('update', {added: this.isDeleted(event) ? [] : [event], removed})
}
}
isDeleted = (event: TrustedEvent) => {
const deletedAt = (
this.deletes.get(event.id) ||
this.deletes.get(getAddress(event)) ||
0
)
return deletedAt > event.created_at
}
// Utilities
_updateIndex<K>(m: Map<K, TrustedEvent[]>, k: K, e: TrustedEvent, duplicate?: TrustedEvent) {
let a = m.get(k) || []
if (duplicate) {
a = a.filter((x: TrustedEvent) => x !== duplicate)
}
a.push(e)
m.set(k, a)
}
}
+382
View File
@@ -0,0 +1,382 @@
import {first, splitAt, identity, sortBy, uniq, shuffle, pushToMapKey} from '@welshman/lib'
import {Tags, Tag} from './Tags'
import type {TrustedEvent} from './Events'
import {isReplaceable} from './Events'
import {isShareableRelayUrl} from './Relay'
import {getAddress, isCommunityAddress, isGroupAddress} from './Address'
export enum RelayMode {
Read = "read",
Write = "write",
}
export type RouterOptions = {
/**
* Retrieves the user's public key.
* @returns The user's public key as a string, or null if not available.
*/
getUserPubkey?: () => string | null
/**
* Retrieves group relays for the specified community.
* @param address - The address to retrieve group relays for.
* @returns An array of group relay URLs as strings.
*/
getGroupRelays?: (address: string) => string[]
/**
* Retrieves relays for the specified community.
* @param address - The address to retrieve community relays for.
* @returns An array of community relay URLs as strings.
*/
getCommunityRelays?: (address: string) => string[]
/**
* Retrieves relays for the specified public key and mode.
* @param pubkey - The public key to retrieve relays for.
* @param mode - The relay mode (optional).
* @returns An array of relay URLs as strings.
*/
getPubkeyRelays?: (pubkey: string, mode?: RelayMode) => string[]
/**
* Retrieves fallback relays, for use when no other relays can be selected.
* @returns An array of relay URLs as strings.
*/
getFallbackRelays: () => string[]
/**
* Retrieves relays likely to support NIP-50 search.
* @returns An array of relay URLs as strings.
*/
getSearchRelays?: () => string[]
/**
* Retrieves the quality of the specified relay.
* @param url - The URL of the relay to retrieve quality for.
* @returns The quality of the relay as a number between 0 and 1 inclusive.
*/
getRelayQuality?: (url: string) => number
/**
* Retrieves the redundancy setting, which is how many relays to use per selection value.
* @returns The redundancy setting as a number.
*/
getRedundancy?: () => number
/**
* Retrieves the limit setting, which is the maximum number of relays that should be
* returned from getUrls and getSelections.
* @returns The limit setting as a number.
*/
getLimit?: () => number
}
export type ValuesByRelay = Map<string, string[]>
export type RelayValues = {
relay: string
values: string[]
}
export type ValueRelays = {
value: string
relays: string[]
}
export type FallbackPolicy = (count: number, limit: number) => number
export class Router {
constructor(readonly options: RouterOptions) {}
// Utilities derived from options
getPubkeySelection = (pubkey: string, mode?: RelayMode) =>
this.selection(pubkey, this.options.getPubkeyRelays?.(pubkey, mode) || [])
getPubkeySelections = (pubkeys: string[], mode?: RelayMode) =>
pubkeys.map(pubkey => this.getPubkeySelection(pubkey, mode))
getUserSelections = (mode?: RelayMode) =>
this.getPubkeySelections([this.options.getUserPubkey?.()].filter(identity) as string[], mode)
getContextSelections = (tags: Tags) => {
return [
...tags.communities().mapTo(t => this.selection(t.value(), this.options.getCommunityRelays?.(t.value()) || [])).valueOf(),
...tags.groups().mapTo(t => this.selection(t.value(), this.options.getGroupRelays?.(t.value()) || [])).valueOf(),
]
}
// Utilities for creating ValueRelays
selection = (value: string, relays: Iterable<string>) => ({value, relays: Array.from(relays)})
selections = (values: string[], relays: string[]) =>
values.map(value => this.selection(value, relays))
forceValue = (value: string, selections: ValueRelays[]) =>
selections.map(({relays}) => this.selection(value, relays))
// Utilities for processing hints
relaySelectionsFromMap = (valuesByRelay: ValuesByRelay) =>
sortBy(
({values}) => -values.length,
Array.from(valuesByRelay)
.map(([relay, values]: [string, string[]]) => ({relay, values: uniq(values)}))
)
scoreRelaySelection = ({values, relay}: RelayValues) =>
values.length * (this.options.getRelayQuality?.(relay) || 1)
sortRelaySelections = (relaySelections: RelayValues[]) => {
const scores = new Map<string, number>()
const getScore = (relayValues: RelayValues) => -(scores.get(relayValues.relay) || 0)
for (const relayValues of relaySelections) {
scores.set(relayValues.relay, this.scoreRelaySelection(relayValues))
}
return sortBy(getScore, relaySelections.filter(getScore))
}
// Utilities for creating scenarios
scenario = (selections: ValueRelays[]) => new RouterScenario(this, selections)
merge = (scenarios: RouterScenario[]) =>
this.scenario(scenarios.flatMap((scenario: RouterScenario) => scenario.selections))
product = (values: string[], relays: string[]) =>
this.scenario(this.selections(values, relays))
fromRelays = (relays: string[]) => this.scenario([this.selection("", relays)])
// Routing scenarios
User = () => this.scenario(this.getUserSelections())
ReadRelays = () => this.scenario(this.getUserSelections(RelayMode.Read))
WriteRelays = () => this.scenario(this.getUserSelections(RelayMode.Write))
Messages = (pubkeys: string[]) =>
this.scenario([
...this.getUserSelections(),
...this.getPubkeySelections(pubkeys),
])
PublishMessage = (pubkey: string) =>
this.scenario([
...this.getUserSelections(RelayMode.Write),
this.getPubkeySelection(pubkey, RelayMode.Read),
]).policy(this.addMinimalFallbacks)
Event = (event: TrustedEvent) =>
this.scenario(this.forceValue(event.id, [
this.getPubkeySelection(event.pubkey, RelayMode.Write),
...this.getContextSelections(Tags.fromEvent(event).context()),
]))
EventChildren = (event: TrustedEvent) =>
this.scenario(this.forceValue(event.id, [
this.getPubkeySelection(event.pubkey, RelayMode.Read),
...this.getContextSelections(Tags.fromEvent(event).context()),
]))
EventAncestors = (event: TrustedEvent, type: "mentions" | "replies" | "roots") => {
const tags = Tags.fromEvent(event)
const ancestors = tags.ancestors()[type]
const pubkeys = tags.values("p").valueOf()
const communities = tags.communities().values().valueOf()
const groups = tags.groups().values().valueOf()
const relays = uniq([
...this.options.getPubkeyRelays?.(event.pubkey, RelayMode.Read) || [],
...pubkeys.flatMap((k: string) => this.options.getPubkeyRelays?.(k, RelayMode.Write) || []),
...communities.flatMap((a: string) => this.options.getCommunityRelays?.(a) || []),
...groups.flatMap((a: string) => this.options.getGroupRelays?.(a) || []),
...ancestors.relays().valueOf(),
])
return this.product(ancestors.values().valueOf(), relays)
}
EventMentions = (event: TrustedEvent) => this.EventAncestors(event, "mentions")
EventParents = (event: TrustedEvent) => this.EventAncestors(event, "replies")
EventRoots = (event: TrustedEvent) => this.EventAncestors(event, "roots")
PublishEvent = (event: TrustedEvent) => {
const tags = Tags.fromEvent(event)
const mentions = tags.values("p").valueOf()
// If we're publishing to private groups, only publish to those groups' relays
if (tags.groups().exists()) {
return this
.scenario(this.getContextSelections(tags.groups()))
.policy(this.addNoFallbacks)
}
return this.scenario(this.forceValue(event.id, [
this.getPubkeySelection(event.pubkey, RelayMode.Write),
...this.getContextSelections(tags.context()),
...this.getPubkeySelections(mentions, RelayMode.Read),
]))
}
FromPubkeys = (pubkeys: string[]) =>
this.scenario(this.getPubkeySelections(pubkeys, RelayMode.Write))
ForPubkeys = (pubkeys: string[]) =>
this.scenario(this.getPubkeySelections(pubkeys, RelayMode.Read))
WithinGroup = (address: string, relays?: string) =>
this
.scenario(this.getContextSelections(Tags.wrap([["a", address]])))
.policy(this.addNoFallbacks)
WithinCommunity = (address: string) =>
this.scenario(this.getContextSelections(Tags.wrap([["a", 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))
Search = (term: string, relays: string[] = []) =>
this.product([term], uniq(relays.concat(this.options.getSearchRelays?.() || [])))
// Fallback policies
addNoFallbacks = (count: number, redundancy: number) => 0
addMinimalFallbacks = (count: number, redundancy: number) => count > 0 ? 0 : 1
addMaximalFallbacks = (count: number, redundancy: number) => redundancy - count
// Higher level utils that use hints
tagPubkey = (pubkey: string) =>
Tag.from(["p", pubkey, this.FromPubkeys([pubkey]).getUrl()])
tagEventId = (event: TrustedEvent, mark = "") =>
Tag.from(["e", event.id, this.Event(event).getUrl(), mark, event.pubkey])
tagEventAddress = (event: TrustedEvent, mark = "") =>
Tag.from(["a", getAddress(event), this.Event(event).getUrl(), mark, event.pubkey])
tagEvent = (event: TrustedEvent, mark = "") => {
const tags = [this.tagEventId(event, mark)]
if (isReplaceable(event)) {
tags.push(this.tagEventAddress(event, mark))
}
return new Tags(tags)
}
}
// Router Scenario
export type RouterScenarioOptions = {
redundancy?: number
policy?: FallbackPolicy
limit?: number
}
export class RouterScenario {
constructor(readonly router: Router, readonly selections: ValueRelays[], readonly options: RouterScenarioOptions = {}) {}
clone = (options: RouterScenarioOptions) =>
new RouterScenario(this.router, this.selections, {...this.options, ...options})
select = (f: (selection: string) => boolean) =>
new RouterScenario(this.router, this.selections.filter(({value}) => f(value)), this.options)
redundancy = (redundancy: number) => this.clone({redundancy})
policy = (policy: FallbackPolicy) => this.clone({policy})
limit = (limit: number) => this.clone({limit})
getRedundancy = () => this.options.redundancy || this.router.options.getRedundancy?.() || 3
getPolicy = () => this.options.policy || this.router.addMaximalFallbacks
getLimit = () => this.options.limit || this.router.options.getLimit?.() || 10
getSelections = () => {
const allValues = new Set()
const valuesByRelay: ValuesByRelay = new Map()
for (const {value, relays} of this.selections) {
allValues.add(value)
for (const relay of relays) {
if (isShareableRelayUrl(relay)) {
pushToMapKey(valuesByRelay, relay, value)
}
}
}
// Adjust redundancy by limit, since if we're looking for very specific values odds
// are we're less tolerant of failure. Add more redundancy to fill our relay limit.
const limit = this.getLimit()
const redundancy = this.getRedundancy()
const adjustedRedundancy = Math.max(redundancy, redundancy * (limit / (allValues.size * redundancy)))
const seen = new Map<string, number>()
const result: ValuesByRelay = new Map()
const relaySelections = this.router.relaySelectionsFromMap(valuesByRelay)
for (const {relay} of this.router.sortRelaySelections(relaySelections)) {
const values = new Set<string>()
for (const value of uniq(valuesByRelay.get(relay) || [])) {
const timesSeen = seen.get(value) || 0
if (timesSeen < adjustedRedundancy) {
seen.set(value, timesSeen + 1)
values.add(value)
}
}
if (values.size > 0) {
result.set(relay, Array.from(values))
}
}
const fallbacks = shuffle(this.router.options.getFallbackRelays())
const fallbackPolicy = this.getPolicy()
for (const {value} of this.selections) {
const timesSeen = seen.get(value) || 0
const fallbacksNeeded = fallbackPolicy(timesSeen, adjustedRedundancy)
if (fallbacksNeeded > 0) {
for (const relay of fallbacks.slice(0, fallbacksNeeded)) {
pushToMapKey(result, relay, value)
}
}
}
const [keep, discard] = splitAt(limit, this.router.relaySelectionsFromMap(result))
for (const target of keep.slice(0, redundancy)) {
target.values = uniq(discard.concat(target).flatMap((selection: RelayValues) => selection.values))
}
return keep
}
getUrls = () => this.getSelections().map((selection: RelayValues) => selection.relay)
getUrl = () => first(this.getUrls())
}
+206
View File
@@ -0,0 +1,206 @@
import {EventTemplate} from 'nostr-tools'
import type {OmitStatics} from '@welshman/lib'
import {Fluent, ensurePlural} from '@welshman/lib'
import {isShareableRelayUrl, normalizeRelayUrl} from './Relay'
import {GROUP, COMMUNITY} from './Kinds'
export class Tag extends (Fluent<string> as OmitStatics<typeof Fluent<string>, 'from'>) {
static from = (xs: Iterable<string>) => new Tag(Array.from(xs))
static fromId = (id: string) => new Tag(["e", id])
static fromIdentifier = (identifier: string) => new Tag(["d", identifier])
static fromTopic = (topic: string) => new Tag(["t", topic])
static fromPubkey = (pubkey: string) => new Tag(["p", pubkey])
static fromAddress = (address: string, relay = "") => new Tag(["a", address, relay])
key = () => this.xs[0]
value = () => this.xs[1]
entry = () => this.xs.slice(0, 2)
setKey = (k: string) => this.set(0, k)
setValue = (v: string) => this.set(1, v)
isAddress = (kind?: number) => this.key() === "a" && this.value()?.startsWith(`${kind}:`)
isGroup = () => this.isAddress(GROUP)
isCommunity = () => this.isAddress(COMMUNITY)
isContext = () => this.isAddress(GROUP) || this.isAddress(COMMUNITY)
}
export class Tags extends (Fluent<Tag> as OmitStatics<typeof Fluent<Tag>, 'from'>) {
static from = (p: Iterable<Tag>) => new Tags(Array.from(p))
static wrap = (p: Iterable<string[]>) => new Tags(Array.from(p).map(Tag.from))
static fromEvent = (event: Pick<EventTemplate, "tags">) => Tags.wrap(event.tags || [])
static fromEvents = (events: Pick<EventTemplate, "tags">[]) => Tags.wrap(events.flatMap(e => e.tags || []))
static fromIMeta = (imeta: string[]) => Tags.wrap(imeta.map((m: string) => m.split(" ")))
unwrap = () => this.xs.map(tag => tag.valueOf())
whereKey = (key: string) => this.filter(t => t.key() === key)
whereValue = (value: string) => this.filter(t => t.value() === value)
filterByKey = (keys: string[]) => this.filter(t => keys.includes(t.key()))
filterByValue = (values: string[]) => this.filter(t => values.includes(t.value()))
rejectByKey = (keys: string[]) => this.reject(t => keys.includes(t.key()))
rejectByValue = (values: string[]) => this.reject(t => values.includes(t.value()))
get = (key: string) => this.whereKey(key).first()
keys = () => this.mapTo(t => t.key())
values = (key?: string | string[]) =>
(key ? this.filterByKey(ensurePlural(key)) : this).mapTo(t => t.value())
entries = () => this.mapTo(t => t.entry())
relays = () => this.flatMap((t: Tag) => t.valueOf().filter(isShareableRelayUrl).map(url => normalizeRelayUrl(url))).uniq()
topics = () => this.whereKey("t").values().map((t: string) => t.replace(/^#/, ""))
ancestors = (x?: boolean) => {
const tags = this.filterByKey(["a", "e", "q"]).reject(t => t.isContext())
const mentionTags = tags.whereKey("q")
const roots: string[][] = []
const replies: string[][] = []
const mentions: string[][] = []
const dispatchTags = (thisTags: Tags) =>
thisTags.forEach((t: Tag, i: number) => {
if (t.nth(3) === 'root') {
if (tags.filter(t => t.nth(3) === "reply").count() === 0) {
replies.push(t.valueOf())
} else {
roots.push(t.valueOf())
}
} else if (t.nth(3) === 'reply') {
replies.push(t.valueOf())
} else if (t.nth(3) === 'mention') {
mentions.push(t.valueOf())
} else if (i === thisTags.count() - 1) {
replies.push(t.valueOf())
} else if (i === 0) {
roots.push(t.valueOf())
} else {
mentions.push(t.valueOf())
}
})
// Add different types separately so positional logic works
dispatchTags(tags.whereKey("e"))
dispatchTags(tags.whereKey("a").filter(t => Boolean(t.nth(3))))
mentionTags.forEach((t: Tag) => mentions.push(t.valueOf()))
return {
roots: Tags.wrap(roots),
replies: Tags.wrap(replies),
mentions: Tags.wrap(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.fromIMeta(tag.drop(1).valueOf())
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.rejectByKey([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.rejectByKey(['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.rejectByKey(['imeta'])
setIMeta = (imeta: Tags[]) => this.removeIMeta().addIMeta(imeta)
}
+126
View File
@@ -0,0 +1,126 @@
import {hexToBech32} from '@welshman/lib'
import type {TrustedEvent} from './Events'
import {Tags} from "./Tags"
const DIVISORS = {
m: BigInt(1e3),
u: BigInt(1e6),
n: BigInt(1e9),
p: BigInt(1e12),
}
const MAX_MILLISATS = BigInt("2100000000000000000")
const MILLISATS_PER_BTC = BigInt(1e11)
export const hrpToMillisat = (hrpString: string) => {
let divisor, value
if (hrpString.slice(-1).match(/^[munp]$/)) {
divisor = hrpString.slice(-1)
value = hrpString.slice(0, -1)
} else if (hrpString.slice(-1).match(/^[^munp0-9]$/)) {
throw new Error("Not a valid multiplier for the amount")
} else {
value = hrpString
}
if (!value.match(/^\d+$/)) throw new Error("Not a valid human readable amount")
const valueBN = BigInt(value)
const millisatoshisBN = divisor
? (valueBN * MILLISATS_PER_BTC) / (DIVISORS as any)[divisor]
: valueBN * MILLISATS_PER_BTC
if (
(divisor === "p" && !(valueBN % BigInt(10) === BigInt(0))) ||
millisatoshisBN > MAX_MILLISATS
) {
throw new Error("Amount is outside of valid range")
}
return millisatoshisBN
}
export const getInvoiceAmount = (bolt11: string) => {
const hrp = bolt11.match(/lnbc(\d+\w)/)
const bn = hrpToMillisat(hrp![1])
return Number(bn)
}
export const getLnUrl = (address: string) => {
if (address.startsWith("lnurl1")) {
return address
}
// If it's a regular url, just encode it
if (address.includes("://")) {
return hexToBech32("lnurl", address)
}
// Try to parse it as a lud16 address
if (address.includes("@")) {
const [name, domain] = address.split("@")
if (domain && name) {
return hexToBech32("lnurl", `https://${domain}/.well-known/lnurlp/${name}`)
}
}
return null
}
export type Zapper = {
lnurl: string
pubkey: string,
callback: string
minSendable: number
maxSendable: number
nostrPubkey: string
allowsNostr: boolean
}
export type Zap = {
request: TrustedEvent
response: TrustedEvent,
invoiceAmount: number
}
export const zapFromEvent = (response: TrustedEvent, zapper: Zapper) => {
const responseMeta = Tags.fromEvent(response).asObject()
let zap: Zap
try {
zap = {
response,
invoiceAmount: getInvoiceAmount(responseMeta.bolt11),
request: JSON.parse(responseMeta.description),
}
} catch (e) {
return null
}
// Don't count zaps that the user sent himself
if (zap.request.pubkey === zapper.pubkey) {
return null
}
const {amount, lnurl} = Tags.fromEvent(zap.request).asObject()
// Verify that the zapper actually sent the requested amount (if it was supplied)
if (amount && parseInt(amount) !== zap.invoiceAmount) {
return null
}
// If the sending client provided an lnurl tag, verify that too
if (lnurl && lnurl !== zapper.lnurl) {
return null
}
// Verify that the request actually came from the recipient's zapper
if (zap.response.pubkey !== zapper.nostrPubkey) {
return null
}
return zap
}
+10
View File
@@ -0,0 +1,10 @@
export * from './Address'
export * from './Events'
export * from './Filters'
export * from './Kinds'
export * from './Links'
export * from './Relay'
export * from './Repository'
export * from './Router'
export * from './Tags'
export * from './Zaps'