import type {Event} from 'nostr-tools' import normalizeUrl from "normalize-url" import {verifyEvent, getEventHash, matchFilter as nostrToolsMatchFilter} from 'nostr-tools' import {cached} from "./LRUCache" // =========================================================================== // General-purpose export const now = () => Math.round(Date.now() / 1000) export const last = (xs: T[]) => xs[xs.length - 1] export const identity = (x: T) => x // =========================================================================== // 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({ 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 } }, }) // ========================================================================== // Tags export class Fluent { xs: any[] constructor(xs: T[]) { this.xs = xs.filter(identity) } as = (f: (xs: T[]) => U) => f(this.xs) all = () => this.xs count = () => this.xs.length exists = () => this.xs.length > 0 first = () => this.xs[0] nth = (i: number) => this.xs[i] last = () => last(this.xs) flat = () => new Fluent(this.xs.flatMap(identity)) uniq = () => new Fluent(Array.from(new Set(this.xs))) drop = (n: number) => new Fluent(this.xs.map(t => t.slice(n))) take = (n: number) => new Fluent(this.xs.map(t => t.slice(0, n))) map = (f: (t: T) => U) => new Fluent(this.xs.map(f)) flatMap = (f: (t: T) => U) => new Fluent(this.xs.flatMap(f)) pluck = (k: number | string) => new Fluent(this.xs.map(x => x[k])) filter = (f: (t: T) => boolean) => new Fluent(this.xs.filter(f)) reject = (f: (t: T) => boolean) => new Fluent(this.xs.filter(t => !f(t))) any = (f: (t: T) => boolean) => this.filter(f).exists() find = (f: (t: T) => boolean) => this.xs.find(f) has = (x: any) => this.xs.includes(x) } export class Tags extends Fluent { static from (e: Event | Event[]) { const events = Array.isArray(e) ? e : [e] return new Tags(events.flatMap(e => e.tags)) } nthEq = (i: number, v: string) => new Tags(this.xs.filter(t => t[i] === v)) values = (k?: string) => this.filter(t => !k || t[0] === k).pluck(1) type(t: string | string[]) { const types = Array.isArray(t) ? t : [t] return new Tags(this.xs.filter(t => types.includes(t[0]))) } mark(m: string | string[]) { const marks = Array.isArray(m) ? m : [m] return new Tags(this.xs.filter(t => marks.includes(last(t)))) } relays = () => this.flat().filter(isShareableRelay).uniq() topics = () => this.type("t").values().map((t: string) => t.replace(/^#/, "")) pubkeys = () => this.type("p").values() urls = () => this.type("r").values() getValue = (k?: string) => this.values(k).first() getDict() { const meta: Record = {} for (const [k, v] of this.xs) { if (!meta[k]) { meta[k] = v } } return meta } getAncestorsLegacy() { // Legacy only supports e tags. Normalize their length to 3 const eTags = this.type("e").map(t => { while (t.length < 3) { t.push("") } return t.slice(0, 3) }) return { roots: eTags.count() > 1 ? new Tags([eTags.first()]) : new Tags([]), replies: new Tags([eTags.last()]), mentions: new Tags(eTags.all().slice(1, -1)), } } getAncestors(type = null) { // If we have a mark, we're not using the legacy format if (!this.any(t => t.length === 4 && ["reply", "root", "mention"].includes(last(t)))) { return this.getAncestorsLegacy() } const tags = new Tags(this.type(type || ["a", "e"]).all().filter(t => !String(t[1]).startsWith('34550:'))) return { roots: new Tags(tags.mark('root').take(3).all()), replies: new Tags(tags.mark('reply').take(3).all()), mentions: new Tags(tags.mark('mention').take(3).all()), } } roots = (type = null) => this.getAncestors(type).roots replies = (type = null) => this.getAncestors(type).replies groups = () => this.type("a").values().filter(a => a.startsWith('35834:')) communities = () => this.type("a").values().filter(a => a.startsWith('34550:')) circles = () => this.type("a").values().filter(a => a.match(/^(34550|35834):/)) getReply = (type = null) => this.replies(type).values().first() getRoot = (type = null) => this.roots(type).values().first() getReplyHints = (type = null) => this.replies(type).relays().all() getRootHints = (type = null) => this.roots(type).relays().all() } // =========================================================================== // 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 }