re work tags again
This commit is contained in:
@@ -7,35 +7,35 @@ A nostr toolkit focused on creating highly a configurable client system. What pa
|
|||||||
Some general-purpose utilities used in paravel.
|
Some general-purpose utilities used in paravel.
|
||||||
|
|
||||||
- `Deferred` is just a promise with `resolve` and `reject` methods.
|
- `Deferred` is just a promise with `resolve` and `reject` methods.
|
||||||
- `Queue` is an implementation of an asynchronous queue.
|
|
||||||
- `LRUCache` is an implementation of an LRU cache.
|
|
||||||
- `Emitter` extends EventEmitter to support `emitter.on('*', ...)`.
|
- `Emitter` extends EventEmitter to support `emitter.on('*', ...)`.
|
||||||
|
- `Fluent` is a wrapper around arrays with chained methods that modify and copy the underlying array.
|
||||||
## /nostr
|
- `Kinds` contains kind constants and related utility functions.
|
||||||
|
- `LRUCache` is an implementation of an LRU cache.
|
||||||
Some nostr-specific utilities.
|
- `Queue` is an implementation of an asynchronous queue.
|
||||||
|
- `Relays` contains utilities related to relays.
|
||||||
- `Router` is a utility for selecting relay urls based on user preferences and protocol hints.
|
- `Router` is a utility for selecting relay urls based on user preferences and protocol hints.
|
||||||
|
- `Tags` and `Tag` extend `Fluent` to provide a convenient way to access and modify tags.
|
||||||
|
- `Tools` is a collection of general-purpose utility functions.
|
||||||
|
|
||||||
## /connect
|
## /connect
|
||||||
|
|
||||||
Utilities having to do with connection management and nostr messages.
|
Utilities having to do with connection management and nostr messages.
|
||||||
|
|
||||||
- `Socket` is a wrapper around isomorphic-ws that handles json parsing/serialization.
|
|
||||||
- `Connection` is a wrapper for `Socket` with send and receive queues, and a `ConnectionMeta` instance.
|
|
||||||
- `ConnectionMeta` tracks stats for a given `Connection`.
|
- `ConnectionMeta` tracks stats for a given `Connection`.
|
||||||
|
- `Connection` is a wrapper for `Socket` with send and receive queues, and a `ConnectionMeta` instance.
|
||||||
- `Executor` implements common nostr flows on `target`
|
- `Executor` implements common nostr flows on `target`
|
||||||
- `Pool` is a thin wrapper around `Map` for use with `Relay`s.
|
- `Pool` is a thin wrapper around `Map` for use with `Relay`s.
|
||||||
|
- `Socket` is a wrapper around isomorphic-ws that handles json parsing/serialization.
|
||||||
- `Subscription` is a higher-level utility for making requests against multiple nostr relays.
|
- `Subscription` is a higher-level utility for making requests against multiple nostr relays.
|
||||||
|
|
||||||
## /connect/target
|
## /connect/target
|
||||||
|
|
||||||
Executor targets extend `Emitter`, and have a `send` method, a `cleanup` method, and a `connections` getter. They are intended to be passed to an `Executor` for use.
|
Executor targets extend `Emitter`, and have a `send` method, a `cleanup` method, and a `connections` getter. They are intended to be passed to an `Executor` for use.
|
||||||
|
|
||||||
|
- `Multi` allows you to compose multiple targets together.
|
||||||
|
- `Plex` takes an array of urls and a `Connection` and sends and receives wrapped nostr messages over that connection.
|
||||||
- `Relay` takes a `Connection` and provides listeners for different verbs.
|
- `Relay` takes a `Connection` and provides listeners for different verbs.
|
||||||
- `Relays` takes an array of `Connection`s and provides listeners for different verbs, merging all events into a single stream.
|
- `Relays` takes an array of `Connection`s and provides listeners for different verbs, merging all events into a single stream.
|
||||||
- `Plex` takes an array of urls and a `Connection` and sends and receives wrapped nostr messages over that connection.
|
|
||||||
- `Multi` allows you to compose multiple targets together.
|
|
||||||
|
|
||||||
# Example
|
# Example
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type {Event, Filter} from 'nostr-tools'
|
import type {Event, Filter} from 'nostr-tools'
|
||||||
import type {Connection} from './Connection'
|
import type {Connection} from './Connection'
|
||||||
import type {Message} from './util/Socket'
|
import type {Message} from './Socket'
|
||||||
|
|
||||||
export type PublishMeta = {
|
export type PublishMeta = {
|
||||||
sent: number
|
sent: number
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
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 {Filter} from '../util/nostr'
|
import type {Filter} from '../util/Filters'
|
||||||
import {matchFilters, hasValidSignature} from "../util/nostr"
|
import {matchFilters} from "../util/Filters"
|
||||||
|
import {hasValidSignature} from "../util/Events"
|
||||||
|
|
||||||
export type SubscriptionOpts = {
|
export type SubscriptionOpts = {
|
||||||
executor: Executor
|
executor: Executor
|
||||||
|
|||||||
+14
-11
@@ -1,19 +1,22 @@
|
|||||||
export * from "./util/misc"
|
|
||||||
export * from "./util/nostr"
|
|
||||||
export * from "./util/LRUCache"
|
|
||||||
export * from "./util/Deferred"
|
|
||||||
export * from "./util/Emitter"
|
|
||||||
export * from "./util/Queue"
|
|
||||||
export * from "./util/Fluent"
|
|
||||||
export * from "./nostr/Tag"
|
|
||||||
export * from "./nostr/Tags"
|
|
||||||
export * from "./connect/Socket"
|
|
||||||
export * from "./connect/Connection"
|
export * from "./connect/Connection"
|
||||||
export * from "./connect/ConnectionMeta"
|
export * from "./connect/ConnectionMeta"
|
||||||
export * from "./connect/Executor"
|
export * from "./connect/Executor"
|
||||||
export * from "./connect/Pool"
|
export * from "./connect/Pool"
|
||||||
|
export * from "./connect/Socket"
|
||||||
export * from "./connect/Subscription"
|
export * from "./connect/Subscription"
|
||||||
|
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 "./connect/target/Multi"
|
export * from "./util/Deferred"
|
||||||
|
export * from "./util/Emitter"
|
||||||
|
export * from "./util/Events"
|
||||||
|
export * from "./util/Filters"
|
||||||
|
export * from "./util/Fluent"
|
||||||
|
export * from "./util/Kinds"
|
||||||
|
export * from "./util/LRUCache"
|
||||||
|
export * from "./util/Queue"
|
||||||
|
export * from "./util/Relays"
|
||||||
|
export * from "./util/Router"
|
||||||
|
export * from "./util/Tags"
|
||||||
|
export * from "./util/Tools"
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import type {OmitStatics} from '../util/misc'
|
|
||||||
import {last} from '../util/misc'
|
|
||||||
import {Fluent} from '../util/Fluent'
|
|
||||||
|
|
||||||
export class Tag extends (Fluent<string> as OmitStatics<typeof Fluent<string>, 'from'>) {
|
|
||||||
static from(parts: Iterable<string>) {
|
|
||||||
return new Tag(Array.from(parts))
|
|
||||||
}
|
|
||||||
|
|
||||||
key = () => this.parts[0]
|
|
||||||
|
|
||||||
value = () => this.parts[1]
|
|
||||||
|
|
||||||
mark = () => last(this.parts.slice(2))
|
|
||||||
|
|
||||||
entry = () => this.parts.slice(0, 2)
|
|
||||||
|
|
||||||
append = (s: string) => Tag.from(this.parts.concat(s))
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import type {Event} from 'nostr-tools'
|
|
||||||
import {Fluent} from '../util/Fluent'
|
|
||||||
import type {OmitStatics} from '../util/misc'
|
|
||||||
import {isIterable, uniq} from '../util/misc'
|
|
||||||
import {isShareableRelay} from '../util/nostr'
|
|
||||||
import {isCommunityAddress, isGroupAddress, isCommunityOrGroupAddress} from './kinds'
|
|
||||||
import {Tag} from './Tag'
|
|
||||||
|
|
||||||
export class Tags extends (Fluent<Tag> as OmitStatics<typeof Fluent<Tag>, 'from'>) {
|
|
||||||
static from(p: Iterable<Tag | string[]>) {
|
|
||||||
return new Tags(Array.from(p).map(Tag.from))
|
|
||||||
}
|
|
||||||
|
|
||||||
static fromEvent(event: Event) {
|
|
||||||
return Tags.from(event.tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
static fromEvents(events: Event[]) {
|
|
||||||
return Tags.from(events.flatMap((e: Event) => e?.tags))
|
|
||||||
}
|
|
||||||
|
|
||||||
// General purpose filters
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
// General purpose methods that return a list of values
|
|
||||||
|
|
||||||
keys = () => new Fluent(this.parts.map(t => t.key()))
|
|
||||||
|
|
||||||
values = () => new Fluent(this.parts.map(t => t.value()))
|
|
||||||
|
|
||||||
marks = () => new Fluent(this.parts.map(t => t.mark()))
|
|
||||||
|
|
||||||
entries = () => new Fluent(this.parts.map(t => t.entry()))
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CoercibleToTags = Event | Iterable<Event> | Tags | Tag | Tag[] | string[] | Iterable<string[]>
|
|
||||||
|
|
||||||
export const coerceToTags = (x: CoercibleToTags) => {
|
|
||||||
const xs = isIterable(x) ? Array.from(x as Iterable<any>) : [x]
|
|
||||||
|
|
||||||
if (xs.length === 0) {
|
|
||||||
return new Tags(xs)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (xs[0] instanceof Event) {
|
|
||||||
return Tags.fromEvents(xs)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (xs[0] instanceof Array) {
|
|
||||||
return Tags.from(xs)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof xs[0] === 'string') {
|
|
||||||
return Tags.from([xs])
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Received invalid value to coerceToTags: ${x}')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fromTags = (tags: Tags) => Array.from(tags).map(tag => Array.from(tag))
|
|
||||||
|
|
||||||
export const getRelays = (x: CoercibleToTags) =>
|
|
||||||
uniq(Array.from(coerceToTags(x)).flatMap((t: Tag) => Array.from(t)).filter(isShareableRelay))
|
|
||||||
|
|
||||||
export const getTopics = (x: CoercibleToTags) =>
|
|
||||||
Array.from(coerceToTags(x).whereKey("t").values()).map((t: string) => t.replace(/^#/, ""))
|
|
||||||
|
|
||||||
export const getPubkeys = (x: CoercibleToTags) =>
|
|
||||||
Array.from(coerceToTags(x).whereKey("p").values())
|
|
||||||
|
|
||||||
export const getUrls = (x: CoercibleToTags) =>
|
|
||||||
Array.from(coerceToTags(x).whereKey("r").values())
|
|
||||||
|
|
||||||
export const getAncestorsLegacy = (x: CoercibleToTags) => {
|
|
||||||
// Legacy only supports e tags. Normalize their length to 3
|
|
||||||
const eTags = Tags.from(
|
|
||||||
coerceToTags(x).whereKey("e").map((t: Tag) => {
|
|
||||||
while (t.count() < 3) {
|
|
||||||
t.append("")
|
|
||||||
}
|
|
||||||
|
|
||||||
return t.slice(0, 3)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
roots: eTags.slice(0, 1),
|
|
||||||
replies: eTags.slice(-1),
|
|
||||||
mentions: eTags.slice(1, -1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetAncestorsReturn = {
|
|
||||||
roots: Tags
|
|
||||||
replies: Tags
|
|
||||||
mentions: Tags
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getAncestors = (x: CoercibleToTags, key?: string): GetAncestorsReturn => {
|
|
||||||
const tags = coerceToTags(x)
|
|
||||||
|
|
||||||
// If we have a mark, we're not using the legacy format
|
|
||||||
if (!tags.some((t: Tag) => t.count() === 4 && ["reply", "root", "mention"].includes(t.mark()))) {
|
|
||||||
return getAncestorsLegacy(tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
const eTags = tags.whereKey("e")
|
|
||||||
const aTags = tags.whereKey("a").reject((t: Tag) => isCommunityOrGroupAddress(t.value()))
|
|
||||||
const allTags = coerceToTags([...eTags, ...aTags])
|
|
||||||
|
|
||||||
return {
|
|
||||||
roots: allTags.whereMark('root').take(3),
|
|
||||||
replies: allTags.whereMark('reply').take(3),
|
|
||||||
mentions: allTags.whereMark('mention').take(3),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getRoots = (x: CoercibleToTags, key?: string) =>
|
|
||||||
getAncestors(x, key).roots
|
|
||||||
|
|
||||||
export const getReplies = (x: CoercibleToTags, key?: string) =>
|
|
||||||
getAncestors(x, key).replies
|
|
||||||
|
|
||||||
export const getGroups = (x: CoercibleToTags) =>
|
|
||||||
coerceToTags(x).whereKey("a").values().filter(isGroupAddress)
|
|
||||||
|
|
||||||
export const getCommunities = (x: CoercibleToTags) =>
|
|
||||||
coerceToTags(x).whereKey("a").values().filter(isCommunityAddress)
|
|
||||||
|
|
||||||
export const getCommunitiesAndGroups = (x: CoercibleToTags) =>
|
|
||||||
coerceToTags(x).whereKey("a").values().filter(isCommunityOrGroupAddress)
|
|
||||||
|
|
||||||
export const getRoot = (x: CoercibleToTags, key?: string) =>
|
|
||||||
getRoots(x, key).values().first()
|
|
||||||
|
|
||||||
export const getReply = (x: CoercibleToTags, key?: string) =>
|
|
||||||
getReplies(x, key).values().first()
|
|
||||||
|
|
||||||
export const getRootHints = (x: CoercibleToTags, key?: string) =>
|
|
||||||
getRelays(getRoots(x, key))
|
|
||||||
|
|
||||||
export const getReplyHints = (x: CoercibleToTags, key?: string) =>
|
|
||||||
getRelays(getReplies(x, key))
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export const GROUP = 35834
|
|
||||||
export const COMMUNITY = 34550
|
|
||||||
|
|
||||||
export const isGroupAddress = (a: string) => a.startsWith(`${GROUP}:`)
|
|
||||||
|
|
||||||
export const isCommunityAddress = (a: string) => a.startsWith(`${COMMUNITY}:`)
|
|
||||||
|
|
||||||
export const isCommunityOrGroupAddress = (a: string) => isCommunityAddress(a) || isGroupAddress(a)
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type {UnsignedEvent} from 'nostr-tools'
|
||||||
|
import {nip19} from 'nostr-tools'
|
||||||
|
import {GROUP_DEFINITION, COMMUNITY_DEFINITION} from './Kinds'
|
||||||
|
import {Tags} from './Tags'
|
||||||
|
|
||||||
|
export const isGroupAddress = (a: string) => a.startsWith(`${GROUP_DEFINITION}:`)
|
||||||
|
|
||||||
|
export const isCommunityAddress = (a: string) => a.startsWith(`${COMMUNITY_DEFINITION}:`)
|
||||||
|
|
||||||
|
export const isCommunityOrGroupAddress = (a: string) => isCommunityAddress(a) || isGroupAddress(a)
|
||||||
|
|
||||||
|
export class Address {
|
||||||
|
readonly kind: number
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
kind: string | number,
|
||||||
|
readonly pubkey: string,
|
||||||
|
readonly identifier: string,
|
||||||
|
readonly relays: string[],
|
||||||
|
) {
|
||||||
|
this.kind = parseInt(kind as string)
|
||||||
|
this.identifier = identifier || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromEvent = (e: UnsignedEvent, relays: string[] = []) =>
|
||||||
|
new Address(e.kind, e.pubkey, Tags.fromEvent(e).whereKey("d").values().first(), relays)
|
||||||
|
|
||||||
|
static fromTagValue = (a: string, relays: string[] = []) => {
|
||||||
|
const [kind, pubkey, identifier] = a.split(":")
|
||||||
|
|
||||||
|
return new Address(kind, pubkey, identifier, relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromTag = (tag: string[], relays: string[] = []) => {
|
||||||
|
const [a, hint] = tag.slice(1)
|
||||||
|
|
||||||
|
if (hint) {
|
||||||
|
relays = relays.concat(hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.fromTagValue(a, relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromNaddr = (naddr: string) => {
|
||||||
|
let type
|
||||||
|
let data = {} as any
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
asTagValue = () => [this.kind, this.pubkey, this.identifier].join(":")
|
||||||
|
|
||||||
|
asTag = (mark?: string) => {
|
||||||
|
const tag = ["a", this.asTagValue(), this.relays[0] || ""]
|
||||||
|
|
||||||
|
if (mark) {
|
||||||
|
tag.push(mark)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
|
||||||
|
asNaddr = () => nip19.naddrEncode(this)
|
||||||
|
|
||||||
|
asFilter = () => ({
|
||||||
|
kinds: [this.kind],
|
||||||
|
authors: [this.pubkey],
|
||||||
|
"#d": [this.identifier],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type {Event, EventTemplate} from 'nostr-tools'
|
||||||
|
import {verifyEvent, getEventHash} from 'nostr-tools'
|
||||||
|
import {cached} from "./LRUCache"
|
||||||
|
import {now} from './Tools'
|
||||||
|
import {Address} 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 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: Rumor) => Address.fromEvent(e).asTagValue()
|
||||||
|
|
||||||
|
export const getIdOrAddress = (e: Rumor) => isReplaceable(e) ? getAddress(e) : e.id
|
||||||
|
|
||||||
|
export const getIdAndAddress = (e: Rumor) => isReplaceable(e) ? [e.id, getAddress(e)] : [e.id]
|
||||||
|
|
||||||
|
export const getIdOrAddressTag = (e: Rumor, hint: string) =>
|
||||||
|
isReplaceable(e) ? ["a", getAddress(e), hint] : ["e", e.id, hint]
|
||||||
|
|
||||||
|
export const isEphemeral = (e: EventTemplate) => isEphemeralKind(e.kind)
|
||||||
|
|
||||||
|
export const isReplaceable = (e: EventTemplate) => isReplaceableKind(e.kind)
|
||||||
|
|
||||||
|
export const isPlainReplaceable = (e: EventTemplate) => isPlainReplaceableKind(e.kind)
|
||||||
|
|
||||||
|
export const isParameterizedReplaceable = (e: EventTemplate) => isParameterizedReplaceableKind(e.kind)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+28
-24
@@ -1,49 +1,53 @@
|
|||||||
import {last} from './misc'
|
import {last} from './Tools'
|
||||||
|
|
||||||
export class Fluent<T> {
|
export class Fluent<T> {
|
||||||
constructor(readonly parts: T[]) {}
|
constructor(readonly xs: T[]) {}
|
||||||
|
|
||||||
static from<T>(parts: Iterable<T>) {
|
static from<T>(xs: Iterable<T>) {
|
||||||
return new Fluent<T>(Array.from(parts))
|
return new Fluent<T>(Array.from(xs))
|
||||||
}
|
}
|
||||||
|
|
||||||
clone<K extends Fluent<T>>(this: K, parts: T[]): K {
|
clone<K extends Fluent<T>>(this: K, xs: T[]): K {
|
||||||
return new (this.constructor as { new (parts: T[]): K })(parts)
|
return new (this.constructor as { new (xs: T[]): K })(xs)
|
||||||
}
|
}
|
||||||
|
|
||||||
*[Symbol.iterator]() {
|
valueOf = () => this.xs
|
||||||
for (const x of this.parts) {
|
|
||||||
yield x
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
first = () => Array.from(this.parts)[0]
|
first = () => this.xs[0]
|
||||||
|
|
||||||
nth = (i: number) => Array.from(this.parts)[i]
|
nth = (i: number) => this.xs[i]
|
||||||
|
|
||||||
last = () => last(Array.from(this.parts))
|
last = () => last(this.xs)
|
||||||
|
|
||||||
count = () => Array.from(this.parts).length
|
count = () => this.xs.length
|
||||||
|
|
||||||
exists = () => Array.from(this.parts).length > 0
|
exists = () => this.xs.length > 0
|
||||||
|
|
||||||
every = (f: (t: T) => boolean) => Array.from(this.parts).every(f)
|
every = (f: (t: T) => boolean) => this.xs.every(f)
|
||||||
|
|
||||||
some = (f: (t: T) => boolean) => Array.from(this.parts).some(f)
|
some = (f: (t: T) => boolean) => this.xs.some(f)
|
||||||
|
|
||||||
find = (f: (t: T) => boolean) => Array.from(this.parts).find(f)
|
find = (f: (t: T) => boolean) => this.xs.find(f)
|
||||||
|
|
||||||
uniq = () => this.clone(Array.from(new Set(this.parts)))
|
uniq = () => this.clone(Array.from(new Set(this.xs)))
|
||||||
|
|
||||||
slice = (a: number, b?: number) => this.clone(Array.from(this.parts).slice(a, b))
|
slice = (a: number, b?: number) => this.clone(this.xs.slice(a, b))
|
||||||
|
|
||||||
take = (n: number) => this.slice(0, n)
|
take = (n: number) => this.slice(0, n)
|
||||||
|
|
||||||
drop = (n: number) => this.slice(n)
|
drop = (n: number) => this.slice(n)
|
||||||
|
|
||||||
filter = (f: (t: T) => boolean) => this.clone(Array.from(this.parts).filter(f))
|
filter = (f: (t: T) => boolean) => this.clone(this.xs.filter(f))
|
||||||
|
|
||||||
reject = (f: (t: T) => boolean) => this.clone(Array.from(this.parts).filter(t => !f(t)))
|
reject = (f: (t: T) => boolean) => this.clone(this.xs.filter(t => !f(t)))
|
||||||
|
|
||||||
map = <U>(f: (t: T) => U) => new Fluent(Array.from(this.parts).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))
|
||||||
|
|
||||||
|
flatMap = <U>(f: (t: T) => U[]) => new Fluent(this.xs.flatMap(f))
|
||||||
|
|
||||||
|
concat = (xs: T[]) => this.clone(this.xs.concat(xs))
|
||||||
|
|
||||||
|
append = (x: T) => this.concat([x])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import {between} from './Tools'
|
||||||
|
|
||||||
|
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,34 @@
|
|||||||
|
import normalizeUrl from "normalize-url"
|
||||||
|
import {stripProtocol} from './Tools'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type {Event} from 'nostr-tools'
|
import type {Event} from 'nostr-tools'
|
||||||
import {Tags, fromTags, getPubkeys, getGroups, getCommunities, getCommunitiesAndGroups, getReplyHints, getRootHints} from './Tags'
|
import {Tags} from './Tags'
|
||||||
import {nth, first} from '../util/misc'
|
import {nth, first} from '../util/Tools'
|
||||||
|
|
||||||
export type RouterOptions = {
|
export type RouterOptions = {
|
||||||
getUserPubkey: () => string | null
|
getUserPubkey: () => string | null
|
||||||
@@ -28,7 +28,7 @@ export class Router {
|
|||||||
getPubkeyRelayTags = (pubkey: string, mode?: string) => {
|
getPubkeyRelayTags = (pubkey: string, mode?: string) => {
|
||||||
const tags = this.options.getPubkeyRelayTags(pubkey)
|
const tags = this.options.getPubkeyRelayTags(pubkey)
|
||||||
|
|
||||||
return mode ? fromTags(Tags.from(tags).whereMark(mode)) : tags
|
return mode ? Tags.from(tags).whereMark(mode).valueOf() : tags
|
||||||
}
|
}
|
||||||
|
|
||||||
getPubkeyRelayUrls = (pubkey: string, mode?: string) =>
|
getPubkeyRelayUrls = (pubkey: string, mode?: string) =>
|
||||||
@@ -47,14 +47,14 @@ export class Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getEventGroupOrCommunityRelayUrlGroups = (event: Event, otherGroups: string[][]) => {
|
getEventGroupOrCommunityRelayUrlGroups = (event: Event, otherGroups: string[][]) => {
|
||||||
const groupAddresses = getGroups(event)
|
const groupAddresses = Tags.fromEvent(event).groups().valueOf()
|
||||||
|
|
||||||
if (groupAddresses.count() > 0) {
|
if (groupAddresses.length > 0) {
|
||||||
return Array.from(groupAddresses.map(this.getGroupRelayUrls))
|
return groupAddresses.map(this.getGroupRelayUrls)
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...getCommunities(event).map(this.getCommunityRelayUrls),
|
...Tags.fromEvent(event).communities().valueOf().map(this.getCommunityRelayUrls),
|
||||||
...otherGroups,
|
...otherGroups,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -138,7 +138,7 @@ export class Router {
|
|||||||
fallbackPolicy: useMaximalFallbacks("read"),
|
fallbackPolicy: useMaximalFallbacks("read"),
|
||||||
getGroups: () =>
|
getGroups: () =>
|
||||||
this.getEventGroupOrCommunityRelayUrlGroups(event, [
|
this.getEventGroupOrCommunityRelayUrlGroups(event, [
|
||||||
getReplyHints(event),
|
Tags.fromEvent(event).replies().relays().valueOf(),
|
||||||
this.getPubkeyRelayUrls(event.pubkey, "read"),
|
this.getPubkeyRelayUrls(event.pubkey, "read"),
|
||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
@@ -147,7 +147,7 @@ export class Router {
|
|||||||
fallbackPolicy: useMaximalFallbacks("read"),
|
fallbackPolicy: useMaximalFallbacks("read"),
|
||||||
getGroups: () =>
|
getGroups: () =>
|
||||||
this.getEventGroupOrCommunityRelayUrlGroups(event, [
|
this.getEventGroupOrCommunityRelayUrlGroups(event, [
|
||||||
getRootHints(event),
|
Tags.fromEvent(event).roots().relays().valueOf(),
|
||||||
this.getPubkeyRelayUrls(event.pubkey, "read"),
|
this.getPubkeyRelayUrls(event.pubkey, "read"),
|
||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
@@ -157,7 +157,7 @@ export class Router {
|
|||||||
getGroups: () =>
|
getGroups: () =>
|
||||||
this.getEventGroupOrCommunityRelayUrlGroups(event, [
|
this.getEventGroupOrCommunityRelayUrlGroups(event, [
|
||||||
this.getPubkeyRelayUrls(event.pubkey, "write"),
|
this.getPubkeyRelayUrls(event.pubkey, "write"),
|
||||||
...getPubkeys(event).map(pubkey => this.getPubkeyRelayUrls(pubkey, "read")),
|
...Tags.fromEvent(event).whereKey("p").values().valueOf().map((pk: string) => this.getPubkeyRelayUrls(pk, "read")),
|
||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ export class RouterScenario {
|
|||||||
if (urls.length < limit) {
|
if (urls.length < limit) {
|
||||||
const {mode, getLimit} = this.options.fallbackPolicy
|
const {mode, getLimit} = this.options.fallbackPolicy
|
||||||
const fallbackRelayTags = this.router.options.getFallbackRelayTags()
|
const fallbackRelayTags = this.router.options.getFallbackRelayTags()
|
||||||
const fallbackUrls = Tags.from(fallbackRelayTags).whereMark(mode).values()
|
const fallbackUrls = Tags.from(fallbackRelayTags).whereMark(mode).values().valueOf()
|
||||||
const fallbackLimit = getLimit(limit, urls)
|
const fallbackLimit = getLimit(limit, urls)
|
||||||
|
|
||||||
return [...urls, ...fallbackUrls.slice(0, fallbackLimit)]
|
return [...urls, ...fallbackUrls.slice(0, fallbackLimit)]
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type {EventTemplate} from 'nostr-tools'
|
||||||
|
import {Fluent} from './Fluent'
|
||||||
|
import type {OmitAllStatics} from './Tools'
|
||||||
|
import {last} from './Tools'
|
||||||
|
import {isShareableRelayUrl} from './Relays'
|
||||||
|
import {isCommunityAddress, isGroupAddress, isCommunityOrGroupAddress} from './Address'
|
||||||
|
|
||||||
|
export class Tag extends (Fluent<string> as OmitAllStatics<typeof Fluent<string>>) {
|
||||||
|
static from(xs: Iterable<string>) {
|
||||||
|
return new Tag(Array.from(xs))
|
||||||
|
}
|
||||||
|
|
||||||
|
valueOf = () => this.xs
|
||||||
|
|
||||||
|
key = () => this.xs[0]
|
||||||
|
|
||||||
|
value = () => this.xs[1]
|
||||||
|
|
||||||
|
mark = () => last(this.xs.slice(2))
|
||||||
|
|
||||||
|
entry = () => this.xs.slice(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Tags extends (Fluent<Tag> as OmitAllStatics<typeof Fluent<Tag>>) {
|
||||||
|
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: Iterable<EventTemplate>) {
|
||||||
|
return Tags.from(Array.from(events).flatMap((e: EventTemplate) => 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)
|
||||||
|
|
||||||
|
keys = () => this.mapTo(t => t.key())
|
||||||
|
|
||||||
|
values = () => 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)).uniq()
|
||||||
|
|
||||||
|
topics = () => this.whereKey("t").values().map((t: string) => t.replace(/^#/, ""))
|
||||||
|
|
||||||
|
getAncestorsLegacy(this: Tags) {
|
||||||
|
// Legacy only supports e tags. Normalize their length to 3
|
||||||
|
const eTags =
|
||||||
|
this
|
||||||
|
.whereKey("e")
|
||||||
|
.map((t: Tag) => t.concat([""]).slice(0, 3))
|
||||||
|
|
||||||
|
return {
|
||||||
|
roots: eTags.slice(0, 1),
|
||||||
|
replies: eTags.slice(-1),
|
||||||
|
mentions: eTags.slice(1, -1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAncestors = (key?: string) => {
|
||||||
|
// If we have a mark, we're not using the legacy format
|
||||||
|
if (!this.some((t: Tag) => t.count() === 4 && ["reply", "root", "mention"].includes(t.mark()))) {
|
||||||
|
return this.getAncestorsLegacy()
|
||||||
|
}
|
||||||
|
|
||||||
|
const eTags = this.whereKey("e")
|
||||||
|
const aTags = this.whereKey("a").reject((t: Tag) => isCommunityOrGroupAddress(t.value()))
|
||||||
|
const allTags = eTags.concat(aTags.xs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
roots: allTags.whereMark('root').map((t: Tag) => t.take(3)),
|
||||||
|
replies: allTags.whereMark('reply').map((t: Tag) => t.take(3)),
|
||||||
|
mentions: allTags.whereMark('mention').map((t: Tag) => t.take(3)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roots = (key?: string) => this.getAncestors(key).roots
|
||||||
|
|
||||||
|
replies = (key?: string) => this.getAncestors(key).replies
|
||||||
|
|
||||||
|
groups = () => this.whereKey("a").values().filter(isGroupAddress)
|
||||||
|
|
||||||
|
communities = () => this.whereKey("a").values().filter(isCommunityAddress)
|
||||||
|
|
||||||
|
communitiesAndGroups = () => this.whereKey("a").values().filter(isCommunityOrGroupAddress)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export const now = () => Math.round(Date.now() / 1000)
|
||||||
|
|
||||||
|
export const nth = (i: number) => <T>(xs: T[]) => xs[i]
|
||||||
|
|
||||||
|
export const first = <T>(xs: T[]) => xs[0]
|
||||||
|
|
||||||
|
export const last = <T>(xs: T[]) => xs[xs.length - 1]
|
||||||
|
|
||||||
|
export const identity = <T>(x: T) => x
|
||||||
|
|
||||||
|
export const between = (low: number, high: number, n: number) => n > low && n < high
|
||||||
|
|
||||||
|
export const flatten = <T>(xs: T[]) => xs.flatMap(identity)
|
||||||
|
|
||||||
|
export const uniq = <T>(xs: T[]) => Array.from(new Set(xs))
|
||||||
|
|
||||||
|
export const isIterable = (x: any) => Symbol.iterator in Object(x)
|
||||||
|
|
||||||
|
export const toIterable = (x: any) => isIterable(x) ? x : [x]
|
||||||
|
|
||||||
|
export const stripProtocol = (url: string) => url.replace(/.*:\/\//, "")
|
||||||
|
|
||||||
|
// https://github.com/microsoft/TypeScript/issues/4628#issuecomment-1147905253
|
||||||
|
export type OmitAllStatics<T extends {new(...args: any[]): any, prototype: any}> =
|
||||||
|
T extends {new(...args: infer A): infer R, prototype: infer P} ?
|
||||||
|
{new(...args: A): R, prototype: P} :
|
||||||
|
never;
|
||||||
|
|
||||||
|
export const fromNostrURI = (s: string) => s.replace(/^[\w+]+:\/?\/?/, "")
|
||||||
|
|
||||||
|
export const toNostrURI = (s: string) => `nostr:${s}`
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
export const now = () => Math.round(Date.now() / 1000)
|
|
||||||
|
|
||||||
export const nth = (i: number) => <T>(xs: T[]) => xs[i]
|
|
||||||
|
|
||||||
export const first = <T>(xs: T[]) => xs[0]
|
|
||||||
|
|
||||||
export const last = <T>(xs: T[]) => xs[xs.length - 1]
|
|
||||||
|
|
||||||
export const identity = <T>(x: T) => x
|
|
||||||
|
|
||||||
export const flatten = <T>(xs: T[]) => xs.flatMap(identity)
|
|
||||||
|
|
||||||
export const uniq = <T>(xs: T[]) => Array.from(new Set(xs))
|
|
||||||
|
|
||||||
// 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>;
|
|
||||||
|
|
||||||
export const isIterable = (x: any) => Symbol.iterator in Object(x)
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import type {Event} from 'nostr-tools'
|
|
||||||
import normalizeUrl from "normalize-url"
|
|
||||||
import {verifyEvent, getEventHash, matchFilter as nostrToolsMatchFilter} from 'nostr-tools'
|
|
||||||
import {cached} from "./LRUCache"
|
|
||||||
import {now} from './misc'
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Relays
|
|
||||||
|
|
||||||
export const stripProto = (url: string) => url.replace(/.*:\/\//, "")
|
|
||||||
|
|
||||||
export const isShareableRelay = (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.match(/\s/) &&
|
|
||||||
// It shouldn't have any url-encoded whitespace
|
|
||||||
!url.match(/%/) &&
|
|
||||||
// Is it secure
|
|
||||||
url.match(/^wss:\/\/.+/) &&
|
|
||||||
// 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 = stripProto(url)
|
|
||||||
|
|
||||||
// Urls without pathnames are supposed to have a trailing slash
|
|
||||||
if (!url.includes("/")) {
|
|
||||||
url += "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "wss://" + url
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Nostr URIs
|
|
||||||
|
|
||||||
export const fromNostrURI = (s: string) => s.replace(/^[\w+]+:\/?\/?/, "")
|
|
||||||
|
|
||||||
export const toNostrURI = (s: string) => `nostr:${s}`
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Events
|
|
||||||
|
|
||||||
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 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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Filters
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user