From 0576c3c0e00c97ad3760871280d8d11f83d6f4b2 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 13 Aug 2024 08:31:53 -0700 Subject: [PATCH] Add domain package --- packages/domain/.eslintignore | 2 + packages/domain/README.md | 3 ++ packages/domain/package.json | 38 ++++++++++++++++++ packages/domain/src/handle.ts | 11 ++++++ packages/domain/src/handler.ts | 50 ++++++++++++++++++++++++ packages/domain/src/index.ts | 6 +++ packages/domain/src/list.ts | 45 +++++++++++++++++++++ packages/domain/src/profile.ts | 71 ++++++++++++++++++++++++++++++++++ packages/domain/src/relay.ts | 63 ++++++++++++++++++++++++++++++ packages/domain/src/util.ts | 48 +++++++++++++++++++++++ packages/domain/tsc-multi.json | 7 ++++ packages/domain/tsconfig.json | 11 ++++++ packages/lib/src/Tools.ts | 22 +++++++++++ packages/store/src/index.ts | 2 +- 14 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 packages/domain/.eslintignore create mode 100644 packages/domain/README.md create mode 100644 packages/domain/package.json create mode 100644 packages/domain/src/handle.ts create mode 100644 packages/domain/src/handler.ts create mode 100644 packages/domain/src/index.ts create mode 100644 packages/domain/src/list.ts create mode 100644 packages/domain/src/profile.ts create mode 100644 packages/domain/src/relay.ts create mode 100644 packages/domain/src/util.ts create mode 100644 packages/domain/tsc-multi.json create mode 100644 packages/domain/tsconfig.json diff --git a/packages/domain/.eslintignore b/packages/domain/.eslintignore new file mode 100644 index 0000000..43e824a --- /dev/null +++ b/packages/domain/.eslintignore @@ -0,0 +1,2 @@ +build +normalize-url diff --git a/packages/domain/README.md b/packages/domain/README.md new file mode 100644 index 0000000..2281f55 --- /dev/null +++ b/packages/domain/README.md @@ -0,0 +1,3 @@ +# @welshman/domain [![version](https://badgen.net/npm/v/@welshman/domain)](https://npmjs.com/package/@welshman/domain) + +A collection of utilities for mapping nostr events to useful data structures. diff --git a/packages/domain/package.json b/packages/domain/package.json new file mode 100644 index 0000000..9093fbd --- /dev/null +++ b/packages/domain/package.json @@ -0,0 +1,38 @@ +{ + "name": "@welshman/domain", + "version": "0.0.1", + "author": "hodlbod", + "license": "MIT", + "description": "A collection of utilities for mapping nostr events to useful data structures.", + "publishConfig": { + "access": "public" + }, + "type": "module", + "files": [ + "build" + ], + "types": "./build/src/index.d.ts", + "exports": { + ".": { + "types": "./build/src/index.d.ts", + "import": "./build/src/index.mjs", + "require": "./build/src/index.cjs" + } + }, + "scripts": { + "pub": "npm run lint && npm run build && npm publish", + "build": "gts clean && tsc-multi", + "lint": "gts lint", + "fix": "gts fix" + }, + "devDependencies": { + "gts": "^5.0.1", + "tsc-multi": "^1.1.0", + "typescript": "~5.1.6" + }, + "dependencies": { + "nostr-tools": "^2.3.2", + "@welshman/lib": "0.0.14", + "@welshman/util": "0.0.25" + } +} diff --git a/packages/domain/src/handle.ts b/packages/domain/src/handle.ts new file mode 100644 index 0000000..72debd3 --- /dev/null +++ b/packages/domain/src/handle.ts @@ -0,0 +1,11 @@ +import {last} from '@welshman/lib' + +export type Handle = { + pubkey: string + nip05: string + nip46: string[] + relays: string[] +} + +export const displayHandle = (handle: Handle) => + handle.nip05.startsWith("_@") ? last(handle.nip05.split("@")) : handle.nip05 diff --git a/packages/domain/src/handler.ts b/packages/domain/src/handler.ts new file mode 100644 index 0000000..87f41cf --- /dev/null +++ b/packages/domain/src/handler.ts @@ -0,0 +1,50 @@ +import {fromPairs, parseJson} from "@welshman/lib" +import {getAddress, Tags} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" + +export type Handler = { + kind: number + name: string + about: string + image: string + identifier: string + event: E + website?: string + lud16?: string + nip05?: string +} + +export const readHandlers = (event: E) => { + const {d: identifier} = fromPairs(event.tags) + const meta = parseJson(event.content) + const normalizedMeta = { + name: meta?.name || meta?.display_name || "", + image: meta?.image || meta?.picture || "", + about: meta?.about || "", + website: meta?.website || "", + lud16: meta?.lud16 || "", + nip05: meta?.nip05 || "", + } + + // If our meta is missing important stuff, don't bother showing it + if (!normalizedMeta.name || !normalizedMeta.image) { + return [] + } + + return Tags.fromEvent(event) + .whereKey("k") + .values() + .valueOf() + .map(kind => ({...normalizedMeta, kind: parseInt(kind), identifier, event})) as Handler[] +} + +export const getHandlerKey = (handler: Handler) => `${handler.kind}:${getAddress(handler.event)}` + +export const displayHandler = (handler?: Handler, fallback = "") => handler?.name || fallback + +export const getHandlerAddress = (event: E) => { + const tags = Tags.fromEvent(event).whereKey("a") + const tag = tags.filter(t => t.last() === "web").first() || tags.first() + + return tag?.value() +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts new file mode 100644 index 0000000..204f533 --- /dev/null +++ b/packages/domain/src/index.ts @@ -0,0 +1,6 @@ +export * from "./handle" +export * from "./handler" +export * from "./list" +export * from "./profile" +export * from "./relay" +export * from "./util" diff --git a/packages/domain/src/list.ts b/packages/domain/src/list.ts new file mode 100644 index 0000000..f58367a --- /dev/null +++ b/packages/domain/src/list.ts @@ -0,0 +1,45 @@ +import {parseJson} from "@welshman/lib" +import {Address, isShareableRelayUrl, TrustedEvent} from "@welshman/util" +import {Encryptable, DecryptedEvent} from "./util" + +export type ListParams = { + kind: number +} + +export type List = ListParams & { + publicTags: string[][] + privateTags: string[][] + event?: DecryptedEvent +} + +export type PublishedList = Omit, "event"> & { + event: DecryptedEvent +} + +export const makeList = (list: ListParams & Partial>): List => + ({publicTags: [], privateTags: [], ...list}) + +const isValidTag = (tag: string[]) => { + if (tag[0] === "p") return tag[1]?.length === 64 + if (tag[0] === "e") return tag[1]?.length === 64 + if (tag[0] === "a") return Address.isAddress(tag[1] || "") + if (tag[0] === "t") return tag[1]?.length > 0 + if (tag[0] === "r") return isShareableRelayUrl(tag[1]) + if (tag[0] === "relay") return isShareableRelayUrl(tag[1]) + + return true +} + +export const readList = (event: DecryptedEvent): PublishedList => { + const getTags = (tags: string[][]) => (Array.isArray(tags) ? tags.filter(isValidTag) : []) + const privateTags = getTags(parseJson(event.plaintext?.content)) + const publicTags = getTags(event.tags) + + return {event, kind: event.kind, publicTags, privateTags} +} + +export const createList = ({kind, publicTags = [], privateTags = []}: List) => + new Encryptable({kind, tags: publicTags}, {content: JSON.stringify(privateTags)}) + +export const editList = ({kind, publicTags = [], privateTags = []}: PublishedList) => + new Encryptable({kind, tags: publicTags}, {content: JSON.stringify(privateTags)}) diff --git a/packages/domain/src/profile.ts b/packages/domain/src/profile.ts new file mode 100644 index 0000000..1ec26e2 --- /dev/null +++ b/packages/domain/src/profile.ts @@ -0,0 +1,71 @@ +import {nip19} from "nostr-tools" +import {ellipsize, parseJson} from "@welshman/lib" +import {PROFILE, TrustedEvent} from "@welshman/util" + +export type Profile = { + name?: string + nip05?: string + lud06?: string + lud16?: string + about?: string + banner?: string + picture?: string + website?: string + display_name?: string + event?: E +} + +export type PublishedProfile = Omit, "event"> & { + event: E +} + +export const isPublishedProfile = (profile: Profile): profile is PublishedProfile => + Boolean(profile.event) + +export const makeProfile = (profile: Partial> = {}): Profile => ({ + name: "", + nip05: "", + lud06: "", + lud16: "", + about: "", + banner: "", + picture: "", + website: "", + display_name: "", + ...profile, +}) + +export const readProfile = (event: E) => { + const profile = parseJson(event.content) || {} + + return {...profile, event} as PublishedProfile +} + +export const createProfile = ({event, ...profile}: Profile) => ({ + kind: PROFILE, + content: JSON.stringify(profile), +}) + +export const editProfile = ({event, ...profile}: PublishedProfile) => ({ + kind: PROFILE, + content: JSON.stringify(profile), + tags: event.tags, +}) + +export const displayPubkey = (pubkey: string) => { + const d = nip19.npubEncode(pubkey) + + return d.slice(0, 8) + "…" + d.slice(-5) +} + +export const displayProfile = (profile?: Profile, fallback = "") => { + const {display_name, name, event} = profile || {} + + if (name) return ellipsize(name, 60) + if (display_name) return ellipsize(display_name, 60) + if (event) return displayPubkey(event.pubkey) + + return fallback +} + +export const profileHasName = (profile?: Profile) => Boolean(profile?.name || profile?.display_name) diff --git a/packages/domain/src/relay.ts b/packages/domain/src/relay.ts new file mode 100644 index 0000000..e89f38e --- /dev/null +++ b/packages/domain/src/relay.ts @@ -0,0 +1,63 @@ +import {last} from "@welshman/lib" +import {LOCAL_RELAY_URL, normalizeRelayUrl as _normalizeRelayUrl} from "@welshman/util" + +// Utils related to bare urls + +export function normalizeRelayUrl(url: string, opts = {}) { + if (url === LOCAL_RELAY_URL) { + return url + } + + try { + return _normalizeRelayUrl(url, opts) + } catch (e) { + return url + } +} + +export const displayRelayUrl = (url: string) => last(url.split("://")).replace(/\/$/, "") + +// Relay profiles + +export type RelayProfile = { + url: string + name?: string + contact?: string + description?: string + supported_nips?: number[] + limitation?: { + payment_required?: boolean + auth_required?: boolean + } +} + +export const makeRelayProfile = (relayProfile: RelayProfile) => relayProfile + +export const filterRelaysByNip = (nip: number, relays: RelayProfile[]) => + relays.filter(r => r.supported_nips?.includes(nip)) + +// Relay policies + +export enum RelayMode { + Read = "read", + Write = "write", + Inbox = "inbox", +} + +export type RelayPolicy = { + url: string + read: boolean + write: boolean + inbox: boolean +} + +export const makeRelayPolicy = ({ + url, + ...relayPolicy +}: Partial & {url: string}): RelayPolicy => ({ + url: normalizeRelayUrl(url), + read: false, + write: false, + inbox: false, + ...relayPolicy, +}) diff --git a/packages/domain/src/util.ts b/packages/domain/src/util.ts new file mode 100644 index 0000000..305c68f --- /dev/null +++ b/packages/domain/src/util.ts @@ -0,0 +1,48 @@ +import type {TrustedEvent} from "@welshman/util" + +export type Encrypt = (x: string) => Promise + +export type EventContent = { + content?: string + tags?: string[][] +} + +export type DecryptedEvent = E & { + plaintext: EventContent +} + +export const asDecryptedEvent = (event: E, plaintext: EventContent) => + ({...event, plaintext}) as DecryptedEvent + +export class Encryptable { + constructor(readonly event: Partial, readonly updates: EventContent) {} + + async reconcile(encrypt: Encrypt) { + const encryptContent = () => { + if (!this.updates.content) return null + + return encrypt(this.updates.content) + } + + const encryptTags = () => { + if (!this.updates.tags) return null + + return Promise.all( + this.updates.tags.map(async tag => { + tag[1] = await encrypt(tag[1]) + + return tag + }) + ) + } + + const [content, tags] = await Promise.all([encryptContent(), encryptTags()]) + + // Updates are optional. If not provided, fall back to the event's content and tags. + return { + ...this.event, + tags: tags || this.event.tags, + content: content || this.event.content, + } + } +} diff --git a/packages/domain/tsc-multi.json b/packages/domain/tsc-multi.json new file mode 100644 index 0000000..6c37019 --- /dev/null +++ b/packages/domain/tsc-multi.json @@ -0,0 +1,7 @@ +{ + "targets": [ + {"extname": ".cjs", "module": "commonjs"}, + {"extname": ".mjs", "module": "esnext", "moduleResolution": "node"} + ], + "projects": ["tsconfig.json"] +} diff --git a/packages/domain/tsconfig.json b/packages/domain/tsconfig.json new file mode 100644 index 0000000..15d351a --- /dev/null +++ b/packages/domain/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../node_modules/gts/tsconfig-google.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "build", + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["esnext", "dom", "dom.iterable"] + }, + "include": ["**/*.ts"] +} diff --git a/packages/lib/src/Tools.ts b/packages/lib/src/Tools.ts index 9db81ed..67b960d 100644 --- a/packages/lib/src/Tools.ts +++ b/packages/lib/src/Tools.ts @@ -121,6 +121,16 @@ export const toggle = (x: T, xs: T[]) => xs.includes(x) ? remove(x, xs) : app export const clamp = ([min, max]: [number, number], n: number) => Math.min(max, Math.max(min, n)) +export const parseJson = (json: string | Nil) => { + if (!json) return null + + try { + return JSON.parse(json) + } catch (e) { + return null + } +} + export const tryCatch = (f: () => T, onError?: (e: Error) => void): T | undefined => { try { const r = f() @@ -137,6 +147,18 @@ export const tryCatch = (f: () => T, onError?: (e: Error) => void): T | undef return undefined } +export const ellipsize = (s: string, l: number, suffix = '...') => { + if (s.length < l * 1.1) { + return s + } + + while (s.length > l && s.includes(' ')) { + s = s.split(' ').slice(0, -1).join(' ') + } + + return s + suffix +} + export const isPojo = (obj: any) => { const hasOwnProperty = Object.prototype.hasOwnProperty const toString = Object.prototype.toString diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 7401a0f..c2353dc 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -102,7 +102,7 @@ export const createEventStore = (repository: Repository< } } -export const deriveEventsMapped = ({ +export const deriveEventsMapped = ({ filters, repository, eventToItem,