From 6e1ad713c3e689775c88568141e863306a0771e6 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 30 Aug 2024 09:46:47 -0700 Subject: [PATCH] Add app package --- .eslintrc.json | 2 +- package-lock.json | 61 ++++++++-- packages/app/.eslintignore | 2 + packages/app/README.md | 3 + packages/app/package.json | 44 +++++++ packages/app/src/collection.ts | 56 +++++++++ packages/app/src/core.ts | 41 +++++++ packages/app/src/follows.ts | 40 +++++++ packages/app/src/freshness.ts | 22 ++++ packages/app/src/handles.ts | 65 +++++++++++ packages/app/src/index.ts | 27 +++++ packages/app/src/mutes.ts | 40 +++++++ packages/app/src/plaintext.ts | 25 ++++ packages/app/src/profiles.ts | 49 ++++++++ packages/app/src/relaySelections.ts | 33 ++++++ packages/app/src/relays.ts | 170 ++++++++++++++++++++++++++++ packages/app/src/session.ts | 82 ++++++++++++++ packages/app/src/storage.ts | 121 ++++++++++++++++++++ packages/app/src/thunk.ts | 83 ++++++++++++++ packages/app/src/topics.ts | 41 +++++++ packages/app/src/util.ts | 77 +++++++++++++ packages/app/src/zappers.ts | 57 ++++++++++ packages/app/tsc-multi.json | 7 ++ packages/app/tsconfig.json | 11 ++ packages/dvm/package.json | 2 +- packages/feeds/package.json | 2 +- packages/lib/src/Tools.ts | 4 + packages/net/package.json | 2 +- packages/net/src/Connection.ts | 2 +- packages/signer/package.json | 2 +- packages/store/package.json | 2 +- packages/store/src/index.ts | 29 +++-- packages/util/src/Profile.ts | 25 ++-- packages/util/src/Zaps.ts | 12 +- 34 files changed, 1193 insertions(+), 48 deletions(-) create mode 100644 packages/app/.eslintignore create mode 100644 packages/app/README.md create mode 100644 packages/app/package.json create mode 100644 packages/app/src/collection.ts create mode 100644 packages/app/src/core.ts create mode 100644 packages/app/src/follows.ts create mode 100644 packages/app/src/freshness.ts create mode 100644 packages/app/src/handles.ts create mode 100644 packages/app/src/index.ts create mode 100644 packages/app/src/mutes.ts create mode 100644 packages/app/src/plaintext.ts create mode 100644 packages/app/src/profiles.ts create mode 100644 packages/app/src/relaySelections.ts create mode 100644 packages/app/src/relays.ts create mode 100644 packages/app/src/session.ts create mode 100644 packages/app/src/storage.ts create mode 100644 packages/app/src/thunk.ts create mode 100644 packages/app/src/topics.ts create mode 100644 packages/app/src/util.ts create mode 100644 packages/app/src/zappers.ts create mode 100644 packages/app/tsc-multi.json create mode 100644 packages/app/tsconfig.json diff --git a/.eslintrc.json b/.eslintrc.json index fd73c8d..d0c24a4 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -18,7 +18,7 @@ ], "rules": { "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-unused-vars": ["error", {"args": "none"}], + "@typescript-eslint/no-unused-vars": ["error", {"args": "none", "destructuredArrayIgnorePattern": "^_"}], "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/ban-ts-comment": "off", "no-useless-escape": "off", diff --git a/package-lock.json b/package-lock.json index b516321..fe006b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -597,6 +597,10 @@ "dev": true, "license": "ISC" }, + "node_modules/@welshman/app": { + "resolved": "packages/app", + "link": true + }, "node_modules/@welshman/content": { "resolved": "packages/content", "link": true @@ -1520,6 +1524,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz", + "integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==", + "engines": { + "node": ">=10" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "dev": true, @@ -1706,6 +1718,11 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz", + "integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==" + }, "node_modules/ignore": { "version": "5.3.1", "dev": true, @@ -2995,8 +3012,9 @@ "license": "MIT" }, "node_modules/throttle-debounce": { - "version": "5.0.0", - "license": "MIT", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", "engines": { "node": ">=12.22" } @@ -3267,6 +3285,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/app": { + "name": "@welshman/app", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@welshman/lib": "0.0.15", + "@welshman/net": "0.0.20", + "@welshman/signer": "0.0.4", + "@welshman/store": "0.0.4", + "@welshman/util": "0.0.29", + "fuse.js": "^7.0.0", + "idb": "^8.0.0", + "svelte": "^4.2.18", + "throttle-debounce": "^5.0.2" + }, + "devDependencies": { + "gts": "^5.0.1", + "tsc-multi": "^1.1.0", + "typescript": "~5.1.6" + } + }, "packages/content": { "name": "@welshman/content", "version": "0.0.8", @@ -3299,12 +3338,12 @@ }, "packages/dvm": { "name": "@welshman/dvm", - "version": "0.0.5", + "version": "0.0.6", "license": "MIT", "dependencies": { "@welshman/lib": "0.0.15", "@welshman/net": "0.0.20", - "@welshman/util": "0.0.28", + "@welshman/util": "0.0.29", "nostr-tools": "^2.7.2" }, "devDependencies": { @@ -3338,11 +3377,11 @@ }, "packages/feeds": { "name": "@welshman/feeds", - "version": "0.0.15", + "version": "0.0.16", "license": "MIT", "dependencies": { "@welshman/lib": "0.0.15", - "@welshman/util": "0.0.28" + "@welshman/util": "0.0.29" }, "devDependencies": { "gts": "^5.0.1", @@ -3380,7 +3419,7 @@ "license": "MIT", "dependencies": { "@welshman/lib": "0.0.15", - "@welshman/util": "0.0.28", + "@welshman/util": "0.0.29", "isomorphic-ws": "^5.0.0", "ws": "^8.16.0" }, @@ -3392,12 +3431,12 @@ }, "packages/signer": { "name": "@welshman/signer", - "version": "0.0.3", + "version": "0.0.4", "license": "MIT", "dependencies": { "@welshman/lib": "0.0.15", "@welshman/net": "0.0.20", - "@welshman/util": "0.0.28", + "@welshman/util": "0.0.29", "nostr-tools": "^2.7.2" }, "devDependencies": { @@ -3412,7 +3451,7 @@ "license": "MIT", "dependencies": { "@welshman/lib": "0.0.15", - "@welshman/util": "0.0.28", + "@welshman/util": "0.0.29", "svelte": "^4.2.18" }, "devDependencies": { @@ -3423,7 +3462,7 @@ }, "packages/util": { "name": "@welshman/util", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "dependencies": { "@welshman/lib": "0.0.15", diff --git a/packages/app/.eslintignore b/packages/app/.eslintignore new file mode 100644 index 0000000..43e824a --- /dev/null +++ b/packages/app/.eslintignore @@ -0,0 +1,2 @@ +build +normalize-url diff --git a/packages/app/README.md b/packages/app/README.md new file mode 100644 index 0000000..b0db427 --- /dev/null +++ b/packages/app/README.md @@ -0,0 +1,3 @@ +# @welshman/store [![version](https://badgen.net/npm/v/@welshman/store)](https://npmjs.com/package/@welshman/store) + +Utilities for dealing with svelte stores when using welshman. diff --git a/packages/app/package.json b/packages/app/package.json new file mode 100644 index 0000000..deaf63c --- /dev/null +++ b/packages/app/package.json @@ -0,0 +1,44 @@ +{ + "name": "@welshman/app", + "version": "0.0.1", + "author": "hodlbod", + "license": "MIT", + "description": "A collection of svelte stores for use in building nostr client applications.", + "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": { + "@welshman/lib": "0.0.15", + "@welshman/net": "0.0.20", + "@welshman/signer": "0.0.4", + "@welshman/store": "0.0.4", + "@welshman/util": "0.0.29", + "fuse.js": "^7.0.0", + "idb": "^8.0.0", + "svelte": "^4.2.18", + "throttle-debounce": "^5.0.2" + } +} diff --git a/packages/app/src/collection.ts b/packages/app/src/collection.ts new file mode 100644 index 0000000..c8c03d3 --- /dev/null +++ b/packages/app/src/collection.ts @@ -0,0 +1,56 @@ +import {readable, derived, type Readable} from 'svelte/store' +import {indexBy, type Maybe, now} from '@welshman/lib' +import {withGetter} from '@welshman/store' +import {getFreshness, setFreshness} from './freshness' + +export const collection = ({ + name, + store, + getKey, + load, +}: { + name: string + store: Readable + getKey: (item: T) => string + load: (key: string, ...args: any) => Promise +}) => { + const indexStore = withGetter(derived(store, $items => indexBy(getKey, $items))) + const getItem = (key: string) => indexStore.get().get(key) + const pending = new Map>>() + + const loadItem = async (key: string, ...args: any[]) => { + if (getFreshness(name, key) > now() - 3600) { + return indexStore.get().get(key) + } + + if (pending.has(key)) { + await pending.get(key) + } else { + setFreshness(name, key, now()) + + const promise = load(key, ...args) + + pending.set(key, promise) + + await promise + + pending.delete(key) + } + + return indexStore.get().get(key) + } + + const deriveItem = (key: Maybe, ...args: any[]) => { + if (!key) { + return readable(undefined) + } + + // If we don't yet have the item, or it's stale, trigger a request for it. The derived + // store will update when it arrives + load(key, ...args) + + return derived(indexStore, $index => $index.get(key)) + } + + return {indexStore, deriveItem, loadItem, getItem} +} diff --git a/packages/app/src/core.ts b/packages/app/src/core.ts new file mode 100644 index 0000000..4f7caa6 --- /dev/null +++ b/packages/app/src/core.ts @@ -0,0 +1,41 @@ +import {first} from "@welshman/lib" +import {Repository, Relay} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Tracker, subscribe as baseSubscribe} from "@welshman/net" +import type {SubscribeRequest} from "@welshman/net" +import {createEventStore} from "@welshman/store" + +export const env: { + DUFFLEPUD_URL?: string +} = { + DUFFLEPUD_URL: undefined, +} + +export const repository = new Repository() + +export const events = createEventStore(repository) + +export const relay = new Relay(repository) + +export const tracker = new Tracker() + +export const subscribe = (request: SubscribeRequest) => { + const sub = baseSubscribe({delay: 50, authTimeout: 3000, ...request}) + + sub.emitter.on("event", (url: string, e: TrustedEvent) => { + repository.publish(e) + }) + + return sub +} + +export const load = (request: SubscribeRequest) => + new Promise(resolve => { + const sub = subscribe({closeOnEose: true, timeout: 3000, ...request}) + const events: TrustedEvent[] = [] + + sub.emitter.on("event", (url: string, e: TrustedEvent) => events.push(e)) + sub.emitter.on("complete", () => resolve(events)) + }) + +export const loadOne = async (request: SubscribeRequest) => first(await load(request)) diff --git a/packages/app/src/follows.ts b/packages/app/src/follows.ts new file mode 100644 index 0000000..c3b0f85 --- /dev/null +++ b/packages/app/src/follows.ts @@ -0,0 +1,40 @@ +import {FOLLOWS, asDecryptedEvent, readList} from '@welshman/util' +import {type TrustedEvent, type PublishedList} from '@welshman/util' +import {type SubscribeRequest} from "@welshman/net" +import {deriveEventsMapped, withGetter} from '@welshman/store' +import {repository, load} from './core' +import {collection} from './collection' +import {ensurePlaintext} from './plaintext' +import {getWriteRelayUrls, loadRelaySelections} from './relaySelections' + +export const follows = withGetter( + deriveEventsMapped(repository, { + filters: [{kinds: [FOLLOWS]}], + itemToEvent: item => item.event, + eventToItem: async (event: TrustedEvent) => + readList( + asDecryptedEvent(event, { + content: await ensurePlaintext(event), + }), + ), + }) +) + +export const { + indexStore: followsByPubkey, + deriveItem: deriveFollows, + loadItem: loadFollows, +} = collection({ + name: "follows", + store: follows, + getKey: follows => follows.event.pubkey, + load: async (pubkey: string, hints = [], request: Partial = {}) => { + const relays = getWriteRelayUrls(await loadRelaySelections(pubkey, hints)) + + return load({ + ...request, + relays: [...relays, ...hints], + filters: [{kinds: [FOLLOWS], authors: [pubkey]}], + }) + }, +}) diff --git a/packages/app/src/freshness.ts b/packages/app/src/freshness.ts new file mode 100644 index 0000000..4e271e0 --- /dev/null +++ b/packages/app/src/freshness.ts @@ -0,0 +1,22 @@ +import {writable} from 'svelte/store' +import {assoc} from '@welshman/lib' +import {withGetter} from '@welshman/store' + +export const freshness = withGetter(writable>({})) + +export const getFreshnessKey = (ns: string, key: string) => `${ns}:${key}` + +export const getFreshness = (ns: string, key: string) => + freshness.get()[getFreshnessKey(ns, key)] || 0 + +export const setFreshness = (ns: string, key: string, ts: number) => + freshness.update(assoc(getFreshnessKey(ns, key), ts)) + +export const setFreshnessBulk = (ns: string, updates: Record) => + freshness.update($freshness => { + for (const [key, ts] of Object.entries(updates)) { + $freshness[key] = ts + } + + return $freshness + }) diff --git a/packages/app/src/handles.ts b/packages/app/src/handles.ts new file mode 100644 index 0000000..a6ef7f2 --- /dev/null +++ b/packages/app/src/handles.ts @@ -0,0 +1,65 @@ +import {writable, derived} from 'svelte/store' +import {withGetter} from '@welshman/store' +import {uniq, indexBy, uniqBy, batcher, postJson, last} from '@welshman/lib' +import {env} from './core' +import {collection} from './collection' +import {profilesByPubkey} from './profiles' + +export type Handle = { + nip05: string + pubkey?: string + nip46?: string[] + relays?: string[] +} + +export const handles = withGetter(writable([])) + +export const handlesByPubkey = derived([profilesByPubkey, handles], ([$profilesByPubkey, $handles]) => + indexBy( + $handle => $handle.pubkey, + $handles.filter($handle => $handle.pubkey && $profilesByPubkey.get($handle.pubkey)?.nip05 === $handle.nip05), + ), +) + +export const fetchHandles = (handles: string[]) => { + const base = env.DUFFLEPUD_URL! + + if (!base) { + throw new Error("DUFFLEPUD_URL is required to fetch nip05 info") + } + + const res: any = postJson(`${base}/handle/info`, {handles}) + const handlesByNip05 = new Map() + + for (const {handle, info} of res?.data || []) { + handlesByNip05.set(handle, info) + } + + return handlesByNip05 +} + +export const { + indexStore: handlesByNip05, + deriveItem: deriveHandle, + loadItem: loadHandle, +} = collection({ + name: "handles", + store: handles, + getKey: (handle: Handle) => handle.nip05, + load: batcher(800, async (nip05s: string[]) => { + const fresh = await fetchHandles(uniq(nip05s)) + const stale = handlesByNip05.get() + const items: Handle[] = nip05s.map(nip05 => { + const handle = fresh.get(nip05) || stale.get(nip05) || {} + + return {...handle, nip05} + }) + + handles.update($handles => uniqBy($handle => $handle.nip05, [...$handles, ...items])) + + return items + }), +}) + +export const displayHandle = (handle: Handle) => + handle.nip05.startsWith("_@") ? last(handle.nip05.split("@")) : handle.nip05 diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts new file mode 100644 index 0000000..c0cae71 --- /dev/null +++ b/packages/app/src/index.ts @@ -0,0 +1,27 @@ +export * from './core' +export * from './collection' +export * from './freshness' +export * from './follows' +export * from './handles' +export * from './mutes' +export * from './plaintext' +export * from './profiles' +export * from './relays' +export * from './relaySelections' +export * from './session' +export * from './storage' +export * from './thunk' +export * from './topics' +export * from './util' +export * from './zappers' + +import {NetworkContext} from "@welshman/net" +import {type TrustedEvent} from "@welshman/util" +import {tracker, repository} from './core' +import {onAuth} from './session' + +Object.assign(NetworkContext, { + onAuth, + onEvent: (url: string, event: TrustedEvent) => tracker.track(event.id, url), + isDeleted: (url: string, event: TrustedEvent) => repository.isDeleted(event), +}) diff --git a/packages/app/src/mutes.ts b/packages/app/src/mutes.ts new file mode 100644 index 0000000..04d0b0d --- /dev/null +++ b/packages/app/src/mutes.ts @@ -0,0 +1,40 @@ +import {MUTES, asDecryptedEvent, readList} from '@welshman/util' +import {type TrustedEvent, type PublishedList} from '@welshman/util' +import {type SubscribeRequest} from "@welshman/net" +import {deriveEventsMapped, withGetter} from '@welshman/store' +import {repository, load} from './core' +import {collection} from './collection' +import {ensurePlaintext} from './plaintext' +import {getWriteRelayUrls, loadRelaySelections} from './relaySelections' + +export const mutes = withGetter( + deriveEventsMapped(repository, { + filters: [{kinds: [MUTES]}], + itemToEvent: item => item.event, + eventToItem: async (event: TrustedEvent) => + readList( + asDecryptedEvent(event, { + content: await ensurePlaintext(event), + }), + ), + }) +) + +export const { + indexStore: mutesByPubkey, + deriveItem: deriveMutes, + loadItem: loadMutes, +} = collection({ + name: "mutes", + store: mutes, + getKey: mute => mute.event.pubkey, + load: async (pubkey: string, hints = [], request: Partial = {}) => { + const relays = getWriteRelayUrls(await loadRelaySelections(pubkey, hints)) + + return load({ + ...request, + relays: [...relays, ...hints], + filters: [{kinds: [MUTES], authors: [pubkey]}], + }) + }, +}) diff --git a/packages/app/src/plaintext.ts b/packages/app/src/plaintext.ts new file mode 100644 index 0000000..1a6c06d --- /dev/null +++ b/packages/app/src/plaintext.ts @@ -0,0 +1,25 @@ +import {writable} from 'svelte/store' +import {assoc} from '@welshman/lib' +import type {TrustedEvent} from '@welshman/util' +import {withGetter} from '@welshman/store' +import {decrypt} from "@welshman/signer" +import {getSigner, getSession} from './session' + +export const plaintext = withGetter(writable>({})) + +export const getPlaintext = (e: TrustedEvent) => plaintext.get()[e.id] + +export const setPlaintext = (e: TrustedEvent, content: string) => + plaintext.update(assoc(e.id, content)) + +export const ensurePlaintext = async (e: TrustedEvent) => { + if (e.content && !getPlaintext(e)) { + const $signer = getSigner(getSession(e.pubkey)) + + if ($signer) { + setPlaintext(e, await decrypt($signer, e.pubkey, e.content)) + } + } + + return getPlaintext(e) +} diff --git a/packages/app/src/profiles.ts b/packages/app/src/profiles.ts new file mode 100644 index 0000000..cfc1480 --- /dev/null +++ b/packages/app/src/profiles.ts @@ -0,0 +1,49 @@ +import {derived} from 'svelte/store' +import {readProfile, displayProfile, displayPubkey, PROFILE} from '@welshman/util' +import {type SubscribeRequest} from "@welshman/net" +import {type PublishedProfile} from "@welshman/util" +import {deriveEventsMapped} from '@welshman/store' +import {repository, load} from './core' +import {createSearch} from './util' +import {collection} from './collection' +import {getWriteRelayUrls, loadRelaySelections} from './relaySelections' + +export const profiles = deriveEventsMapped(repository, { + filters: [{kinds: [PROFILE]}], + eventToItem: readProfile, + itemToEvent: item => item.event, +}) + +export const { + indexStore: profilesByPubkey, + deriveItem: deriveProfile, + loadItem: loadProfile, +} = collection({ + name: "profiles", + store: profiles, + getKey: profile => profile.event.pubkey, + load: async (pubkey: string, hints = [], request: Partial = {}) => { + const relays = getWriteRelayUrls(await loadRelaySelections(pubkey)) + + return load({ + ...request, + relays: [...relays, ...hints], + filters: [{kinds: [PROFILE], authors: [pubkey]}], + }) + }, +}) + +export const profileSearch = derived(profiles, $profiles => + createSearch($profiles, { + getValue: (profile: PublishedProfile) => profile.event.pubkey, + fuseOptions: { + keys: ["name", "display_name", {name: "about", weight: 0.3}], + }, + }), +) + +export const displayProfileByPubkey = (pubkey: string) => + displayProfile(profilesByPubkey.get().get(pubkey), displayPubkey(pubkey)) + +export const deriveProfileDisplay = (pubkey: string) => + derived(deriveProfile(pubkey), $profile => displayProfile($profile, displayPubkey(pubkey))) diff --git a/packages/app/src/relaySelections.ts b/packages/app/src/relaySelections.ts new file mode 100644 index 0000000..cf1f5a6 --- /dev/null +++ b/packages/app/src/relaySelections.ts @@ -0,0 +1,33 @@ +import {RELAYS, getRelayTags, normalizeRelayUrl, type TrustedEvent} from '@welshman/util' +import {type SubscribeRequest} from "@welshman/net" +import {deriveEvents} from '@welshman/store' +import {load, repository} from './core' +import {collection} from './collection' + +export const getReadRelayUrls = (event?: TrustedEvent): string[] => + getRelayTags(event?.tags || []) + .filter((t: string[]) => !t[2] || t[2] === "read") + .map((t: string[]) => normalizeRelayUrl(t[1])) + +export const getWriteRelayUrls = (event?: TrustedEvent): string[] => + getRelayTags(event?.tags || []) + .filter((t: string[]) => !t[2] || t[2] === "write") + .map((t: string[]) => normalizeRelayUrl(t[1])) + +export const relaySelections = deriveEvents(repository, {filters: [{kinds: [RELAYS]}]}) + +export const { + indexStore: relaySelectionsByPubkey, + deriveItem: deriveRelaySelections, + loadItem: loadRelaySelections, +} = collection({ + name: "relaySelections", + store: relaySelections, + getKey: relaySelections => relaySelections.pubkey, + load: (pubkey: string, relays: string[], request: Partial = {}) => + load({ + ...request, + relays, + filters: [{kinds: [RELAYS], authors: [pubkey]}], + }), +}) diff --git a/packages/app/src/relays.ts b/packages/app/src/relays.ts new file mode 100644 index 0000000..01ec715 --- /dev/null +++ b/packages/app/src/relays.ts @@ -0,0 +1,170 @@ +import {writable, derived} from 'svelte/store' +import {withGetter} from '@welshman/store' +import {groupBy, batch, nth, now, uniq, uniqBy, batcher, postJson} from '@welshman/lib' +import {type RelayProfile} from "@welshman/util" +import {AuthStatus, asMessage, type Connection, type SocketMessage} from '@welshman/net' +import {env} from './core' +import {createSearch} from './util' +import {collection} from './collection' + +export type RelayStats = { + first_seen: number + event_count: number + request_count: number + publish_count: number + connect_count: number + recent_errors: number[] + last_auth_status: AuthStatus +} + +// Relays + +export const makeRelayStats = (): RelayStats => ({ + first_seen: now(), + event_count: 0, + request_count: 0, + publish_count: 0, + connect_count: 0, + recent_errors: [], + last_auth_status: AuthStatus.Pending, +}) + +export type Relay = { + url: string + stats?: RelayStats + profile?: RelayProfile +} + +export const relays = withGetter(writable([])) + +export const relaysByPubkey = derived(relays, $relays => + groupBy( + $relay => $relay.profile?.pubkey, + $relays.filter($relay => $relay.profile?.pubkey), + ), +) + +export const fetchRelayProfiles = (urls: string[]) => { + const base = env.DUFFLEPUD_URL! + + if (!base) { + throw new Error("DUFFLEPUD_URL is required to fetch relay metadata") + } + + const res: any = postJson(`${base}/relay/info`, {urls}) + const profilesByUrl = new Map() + + for (const {url, info} of res?.data || []) { + profilesByUrl.set(url, info) + } + + return profilesByUrl +} + +export const { + indexStore: relaysByUrl, + deriveItem: deriveRelay, + loadItem: loadRelay, +} = collection({ + name: "relays", + store: relays, + getKey: (relay: Relay) => relay.url, + load: batcher(800, async (urls: string[]) => { + const profilesByUrl = await fetchRelayProfiles(uniq(urls)) + const index = relaysByUrl.get() + const items: Relay[] = urls.map(url => { + const relay = index.get(url) + const profile = profilesByUrl.get(url) || relay?.profile + + return {...relay, profile, url} + }) + + relays.update($relays => uniqBy($relay => $relay.url, [...$relays, ...items])) + + return items + }), +}) + +export const relaySearch = derived(relays, $relays => + createSearch($relays, { + getValue: (relay: Relay) => relay.url, + fuseOptions: { + keys: ["url", "name", {name: "description", weight: 0.3}], + }, + }), +) + +// Utilities for syncing stats from connections to relays + +type RelayStatsUpdate = [string, (stats: RelayStats) => void] + +const updateRelayStats = batch(500, (updates: RelayStatsUpdate[]) => { + const updatesByUrl = groupBy(nth(0), updates) + + relays.update($relays => { + return $relays.map($relay => { + for (const [_, update] of updatesByUrl.get($relay.url) || []) { + if (!$relay.stats) { + $relay.stats = makeRelayStats() + } + + update($relay.stats) + } + + return $relay + }) + }) +}) + +const onConnectionError = ({url}: Connection) => + updateRelayStats([url, stats => { + stats.recent_errors = stats.recent_errors.concat(now()).slice(-10) + }]) + +const onConnectionSend = ({url}: Connection, socketMessage: SocketMessage) => { + const [verb] = asMessage(socketMessage) + + if (verb === 'REQ') { + updateRelayStats([url, stats => { + stats.request_count = stats.request_count + 1 + }]) + } else if (verb === 'EVENT') { + updateRelayStats([url, stats => { + stats.publish_count = stats.publish_count + 1 + }]) + } +} + +const onConnectionReceive = ({url}: Connection, socketMessage: SocketMessage) => { + const [verb] = asMessage(socketMessage) + + if (verb === 'EVENT') { + updateRelayStats([url, stats => { + stats.event_count = stats.event_count + 1 + }]) + } else if (verb === 'OK') { + updateRelayStats([url, stats => { + stats.last_auth_status = AuthStatus.Ok + }]) + } else if (verb === 'AUTH') { + updateRelayStats([url, stats => { + stats.last_auth_status = AuthStatus.Unauthorized + }]) + } +} + +export const trackRelayStats = (connection: Connection) => { + updateRelayStats([connection.url, stats => { + stats.connect_count = stats.connect_count + 1 + }]) + + connection.on('error', onConnectionError) + connection.on('send', onConnectionSend) + connection.on('receive', onConnectionReceive) + + return () => { + connection.off('error', onConnectionError) + connection.off('send', onConnectionSend) + connection.off('receive', onConnectionReceive) + } +} diff --git a/packages/app/src/session.ts b/packages/app/src/session.ts new file mode 100644 index 0000000..0c7a9fc --- /dev/null +++ b/packages/app/src/session.ts @@ -0,0 +1,82 @@ +import {derived} from "svelte/store" +import {memoize, omit, equals, assoc} from "@welshman/lib" +import {createEvent} from "@welshman/util" +import {withGetter, synced} from "@welshman/store" +import {type Nip46Handler} from "@welshman/signer" +import {NetworkContext} from "@welshman/net" +import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer} from "@welshman/signer" + +export type Session = { + method: string + pubkey: string + token?: string + secret?: string + handler?: Nip46Handler +} + +export const pubkey = withGetter(synced("pubkey", null)) + +export const sessions = withGetter(synced>("sessions", {})) + +export const session = withGetter( + derived([pubkey, sessions], ([$pubkey, $sessions]) => ($pubkey ? $sessions[$pubkey] : null)), +) + +export const getSession = (pubkey: string) => sessions.get()[pubkey] + +export const addSession = (session: Session) => { + sessions.update(assoc(session.pubkey, session)) + pubkey.set(session.pubkey) +} + +export const putSession = (session: Session) => { + if (!equals(getSession(session.pubkey), session)) { + sessions.update(assoc(session.pubkey, session)) + } +} + +export const updateSession = (pubkey: string, f: (session: Session) => Session) => + putSession(f(getSession(pubkey))) + +export const dropSession = (pubkey: string) => + sessions.update($sessions => omit([pubkey], $sessions)) + +export const nip46Perms = "sign_event:22242,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt" + +export const getSigner = memoize((session: Session) => { + switch (session?.method) { + case "extension": + return new Nip07Signer() + case "privkey": + return new Nip01Signer(session.secret!) + case "nip46": + return new Nip46Signer(Nip46Broker.get(session.pubkey, session.secret!, session.handler!)) + default: + return null + } +}) + +export const signer = withGetter(derived(session, getSigner)) + +export const authChallenges = new Set() + +export const onAuth = async (url: string, challenge: string) => { + if (authChallenges.has(challenge) || !signer.get()) { + return + } + + authChallenges.add(challenge) + + const event = await signer.get()!.sign( + createEvent(22242, { + tags: [ + ["relay", url], + ["challenge", challenge], + ], + }), + ) + + NetworkContext.pool.get(url).send(["AUTH", event]) + + return event +} diff --git a/packages/app/src/storage.ts b/packages/app/src/storage.ts new file mode 100644 index 0000000..12bbc94 --- /dev/null +++ b/packages/app/src/storage.ts @@ -0,0 +1,121 @@ +import {openDB, deleteDB} from "idb" +import type {IDBPDatabase} from "idb" +import {throttle} from "throttle-debounce" +import {writable} from "svelte/store" +import type {Unsubscriber, Writable} from "svelte/store" +import {randomInt} from "@welshman/lib" +import {withGetter} from "@welshman/store" + +export type Item = Record + +export type IndexedDbAdapter = { + keyPath: string + store: Writable +} + +export let db: IDBPDatabase + +export const dead = withGetter(writable(false)) + +export const subs: Unsubscriber[] = [] + +export const DB_NAME = "flotilla" + +export const getAll = async (name: string) => { + const tx = db.transaction(name, "readwrite") + const store = tx.objectStore(name) + const result = await store.getAll() + + await tx.done + + return result +} + +export const bulkPut = async (name: string, data: any[]) => { + const tx = db.transaction(name, "readwrite") + const store = tx.objectStore(name) + + await Promise.all(data.map(item => store.put(item))) + await tx.done +} + +export const bulkDelete = async (name: string, ids: string[]) => { + const tx = db.transaction(name, "readwrite") + const store = tx.objectStore(name) + + await Promise.all(ids.map(id => store.delete(id))) + await tx.done +} + +export const initIndexedDbAdapter = async (name: string, adapter: IndexedDbAdapter) => { + let copy = await getAll(name) + + adapter.store.set(copy) + + adapter.store.subscribe( + throttle(randomInt(3000, 5000), async (data: Item[]) => { + if (dead.get()) { + return + } + + const prevIds = new Set(copy.map(item => item[adapter.keyPath])) + const currentIds = new Set(data.map(item => item[adapter.keyPath])) + const newRecords = data.filter(r => !prevIds.has(r[adapter.keyPath])) + const removedRecords = copy.filter(r => !currentIds.has(r[adapter.keyPath])) + + copy = data + + if (newRecords.length > 0) { + await bulkPut(name, newRecords) + } + + if (removedRecords.length > 0) { + await bulkDelete( + name, + removedRecords.map(item => item[adapter.keyPath]), + ) + } + }), + ) +} + +export const initStorage = async (version: number, adapters: Record) => { + if (!window.indexedDB) return + + window.addEventListener("beforeunload", () => closeStorage()) + + db = await openDB(DB_NAME, version, { + upgrade(db: IDBPDatabase) { + const names = Object.keys(adapters) + + for (const name of db.objectStoreNames) { + if (!names.includes(name)) { + db.deleteObjectStore(name) + } + } + + for (const [name, {keyPath}] of Object.entries(adapters)) { + try { + db.createObjectStore(name, {keyPath}) + } catch (e) { + console.warn(e) + } + } + }, + }) + + await Promise.all( + Object.entries(adapters).map(([name, config]) => initIndexedDbAdapter(name, config)), + ) +} + +export const closeStorage = async () => { + dead.set(true) + subs.forEach(unsub => unsub()) + await db?.close() +} + +export const clearStorage = async () => { + await closeStorage() + await deleteDB(DB_NAME) +} diff --git a/packages/app/src/thunk.ts b/packages/app/src/thunk.ts new file mode 100644 index 0000000..978af05 --- /dev/null +++ b/packages/app/src/thunk.ts @@ -0,0 +1,83 @@ +import {writable, get} from 'svelte/store' +import {Worker, assoc} from '@welshman/lib' +import {stamp, own, hash} from "@welshman/signer" +import type {HashedEvent, EventTemplate, SignedEvent} from '@welshman/util' +import {publish, PublishStatus} from "@welshman/net" +import {repository} from './core' +import {pubkey, getSession, getSigner} from './session' + +export type PublishStatusData = { + id: string + url: string + message: string + status: PublishStatus +} + +export type PublishStatusDataByUrl = Record + +export type PublishStatusDataByUrlById = Record + +export const publishStatusData = writable({}) + +export type Thunk = { + event: HashedEvent + relays: string[] +} + +export type ThunkWithResolve = Thunk & { + resolve: (data: PublishStatusDataByUrl) => void +} + +export const thunkWorker = new Worker() + +thunkWorker.addGlobalHandler(async ({event, relays, resolve}: ThunkWithResolve) => { + const session = getSession(event.pubkey) + + if (!session) { + return console.warn(`No session found for ${event.pubkey}`) + } + + const signedEvent = await getSigner(session)!.sign(event) + const pub = publish({event: signedEvent, relays}) + + // Copy the signature over since we had deferred it + ;(repository.getEvent(signedEvent.id) as SignedEvent).sig = signedEvent.sig + + // Track publish success + const {id} = event + const statusByUrl: PublishStatusDataByUrl = {} + + pub.emitter.on("*", (status: PublishStatus, url: string, message: string) => { + publishStatusData.update( + assoc(id, Object.assign(statusByUrl, {[url]: {id, url, status, message}})), + ) + + if ( + Object.values(statusByUrl).filter(s => s.status !== PublishStatus.Pending).length === + relays.length + ) { + resolve(statusByUrl) + } + }) +}) + +export type ThunkParams = { + event: EventTemplate + relays: string[] +} + +export const makeThunk = ({event, relays}: ThunkParams) => { + const $pubkey = get(pubkey) + + if (!$pubkey) { + throw new Error("Unable to make thunk if no user is logged in") + } + + return {event: hash(own(stamp(event), $pubkey)), relays} +} + +export const publishThunk = (thunk: Thunk) => + new Promise(resolve => { + thunkWorker.push({...thunk, resolve}) + repository.publish(thunk.event) + }) diff --git a/packages/app/src/topics.ts b/packages/app/src/topics.ts new file mode 100644 index 0000000..dee9a41 --- /dev/null +++ b/packages/app/src/topics.ts @@ -0,0 +1,41 @@ +import {throttle} from 'throttle-debounce' +import {derived} from 'svelte/store' +import {inc} from '@welshman/lib' +import {custom} from '@welshman/store' +import {createSearch} from './util' +import {repository} from './core' + +export type Topic = { + name: string + count: number +} + +export const topics = custom(setter => { + const getTopics = () => { + const topics = new Map() + for (const tagString of repository.eventsByTag.keys()) { + if (tagString.startsWith("t:")) { + const topic = tagString.slice(2).toLowerCase() + + topics.set(topic, inc(topics.get(topic))) + } + } + + return Array.from(topics.entries()).map(([name, count]) => ({name, count})) + } + + setter(getTopics()) + + const onUpdate = throttle(3000, () => setter(getTopics())) + + repository.on("update", onUpdate) + + return () => repository.off("update", onUpdate) +}) + +export const topicSearch = derived(topics, $topics => + createSearch($topics, { + getValue: (topic: Topic) => topic.name, + fuseOptions: {keys: ["name"]}, + }), +) diff --git a/packages/app/src/util.ts b/packages/app/src/util.ts new file mode 100644 index 0000000..6e6777c --- /dev/null +++ b/packages/app/src/util.ts @@ -0,0 +1,77 @@ +import Fuse from "fuse.js" +import type {IFuseOptions, FuseResult} from "fuse.js" +import {sortBy} from "@welshman/lib" + +export type SearchOptions = { + getValue: (item: T) => V + fuseOptions?: IFuseOptions + sortFn?: (items: FuseResult) => any +} + +export type Search = { + options: T[] + getValue: (item: T) => V + getOption: (value: V) => T | undefined + searchOptions: (term: string) => T[] + searchValues: (term: string) => V[] +} + +export const createSearch = (options: T[], opts: SearchOptions): Search => { + const fuse = new Fuse(options, {...opts.fuseOptions, includeScore: true}) + const map = new Map(options.map(item => [opts.getValue(item), item])) + + const search = (term: string) => { + let results = term ? fuse.search(term) : options.map(item => ({item, score: 1}) as FuseResult) + + if (opts.sortFn) { + results = sortBy(opts.sortFn, results) + } + + return results.map(result => result.item) + } + + return { + options, + getValue: opts.getValue, + getOption: (value: V) => map.get(value), + searchOptions: (term: string) => search(term), + searchValues: (term: string) => search(term).map(opts.getValue), + } +} + +export const secondsToDate = (ts: number) => new Date(ts * 1000) + +export const dateToSeconds = (date: Date) => Math.round(date.valueOf() / 1000) + +export const getTimeZone = () => new Date().toString().match(/GMT[^\s]+/) + +export const createLocalDate = (dateString: any) => new Date(`${dateString} ${getTimeZone()}`) + +export const getLocale = () => new Intl.DateTimeFormat().resolvedOptions().locale + +export const formatTimestamp = (ts: number) => { + const formatter = new Intl.DateTimeFormat(getLocale(), { + dateStyle: "short", + timeStyle: "short", + }) + + return formatter.format(secondsToDate(ts)) +} + +export const formatTimestampAsDate = (ts: number) => { + const formatter = new Intl.DateTimeFormat(getLocale(), { + year: "numeric", + month: "long", + day: "numeric", + }) + + return formatter.format(secondsToDate(ts)) +} + +export const formatTimestampAsTime = (ts: number) => { + const formatter = new Intl.DateTimeFormat(getLocale(), { + timeStyle: "short", + }) + + return formatter.format(secondsToDate(ts)) +} diff --git a/packages/app/src/zappers.ts b/packages/app/src/zappers.ts new file mode 100644 index 0000000..5efa6ac --- /dev/null +++ b/packages/app/src/zappers.ts @@ -0,0 +1,57 @@ +import {writable, derived} from 'svelte/store' +import {withGetter} from '@welshman/store' +import type {Zapper} from '@welshman/util' +import {uniq, bech32ToHex, indexBy, tryCatch, uniqBy, batcher, postJson} from '@welshman/lib' +import {env} from './core' +import {collection} from './collection' +import {profilesByPubkey} from './profiles' + +export const zappers = withGetter(writable([])) + +export const zappersByPubkey = derived([profilesByPubkey, zappers], ([$profilesByPubkey, $zappers]) => + indexBy( + $zapper => $zapper.pubkey, + $zappers.filter($zapper => $zapper.pubkey && $profilesByPubkey.get($zapper.pubkey)?.lnurl === $zapper.lnurl), + ), +) + +export const fetchZappers = (lnurls: string[]) => { + const base = env.DUFFLEPUD_URL! + + if (!base) { + throw new Error("DUFFLEPUD_URL is required to fetch zapper info") + } + + const res: any = postJson(`${base}/zapper/info`, {lnurls: lnurls.map(bech32ToHex)}) + const zappersByLnurl = new Map() + + for (const {lnurl, info} of res?.data || []) { + tryCatch(() => zappersByLnurl.set(bech32ToHex(lnurl), info)) + } + + return zappersByLnurl +} + +export const { + indexStore: zappersByLnurl, + deriveItem: deriveZapper, + loadItem: loadZapper, +} = collection({ + name: "zappers", + store: zappers, + getKey: (zapper: Zapper) => zapper.lnurl, + load: batcher(800, async (lnurls: string[]) => { + const fresh = await fetchZappers(uniq(lnurls)) + const stale = zappersByLnurl.get() + const items: Zapper[] = lnurls.map(lnurl => { + const zapper = fresh.get(lnurl) || stale.get(lnurl) || {} + + return {...zapper, lnurl} + }) + + zappers.update($zappers => uniqBy($zapper => $zapper.lnurl, [...$zappers, ...items])) + + return items + }), +}) + diff --git a/packages/app/tsc-multi.json b/packages/app/tsc-multi.json new file mode 100644 index 0000000..6c37019 --- /dev/null +++ b/packages/app/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/app/tsconfig.json b/packages/app/tsconfig.json new file mode 100644 index 0000000..15d351a --- /dev/null +++ b/packages/app/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/dvm/package.json b/packages/dvm/package.json index 5380af7..3673280 100644 --- a/packages/dvm/package.json +++ b/packages/dvm/package.json @@ -33,7 +33,7 @@ "dependencies": { "@welshman/lib": "0.0.15", "@welshman/net": "0.0.20", - "@welshman/util": "0.0.28", + "@welshman/util": "0.0.29", "nostr-tools": "^2.7.2" } } diff --git a/packages/feeds/package.json b/packages/feeds/package.json index 58e425e..0ea976c 100644 --- a/packages/feeds/package.json +++ b/packages/feeds/package.json @@ -32,6 +32,6 @@ }, "dependencies": { "@welshman/lib": "0.0.15", - "@welshman/util": "0.0.28" + "@welshman/util": "0.0.29" } } diff --git a/packages/lib/src/Tools.ts b/packages/lib/src/Tools.ts index ce2ea73..4a5dbf1 100644 --- a/packages/lib/src/Tools.ts +++ b/packages/lib/src/Tools.ts @@ -139,6 +139,10 @@ export const parseJson = (json: string | Nil) => { } } +export const getJson = (k: string) => parseJson(localStorage.getItem(k) || "") + +export const setJson = (k: string, v: any) => localStorage.setItem(k, JSON.stringify(v)) + export const tryCatch = (f: () => T, onError?: (e: Error) => void): T | undefined => { try { const r = f() diff --git a/packages/net/package.json b/packages/net/package.json index 606b61b..14196e1 100644 --- a/packages/net/package.json +++ b/packages/net/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@welshman/lib": "0.0.15", - "@welshman/util": "0.0.28", + "@welshman/util": "0.0.29", "isomorphic-ws": "^5.0.0", "ws": "^8.16.0" } diff --git a/packages/net/src/Connection.ts b/packages/net/src/Connection.ts index f43aa5a..618aa3b 100644 --- a/packages/net/src/Connection.ts +++ b/packages/net/src/Connection.ts @@ -118,7 +118,7 @@ export class Connection extends Emitter { new Promise(resolve => { this.on('receive', (cxn: Connection, message: Message) => { if (message[0] === 'OK' && message[2]) { - resolve() + resolve() } }) }) diff --git a/packages/signer/package.json b/packages/signer/package.json index b8a77cf..329644a 100644 --- a/packages/signer/package.json +++ b/packages/signer/package.json @@ -33,7 +33,7 @@ "dependencies": { "@welshman/lib": "0.0.15", "@welshman/net": "0.0.20", - "@welshman/util": "0.0.28", + "@welshman/util": "0.0.29", "nostr-tools": "^2.7.2" } } diff --git a/packages/store/package.json b/packages/store/package.json index 9894352..dc00ea3 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@welshman/lib": "0.0.15", - "@welshman/util": "0.0.28", + "@welshman/util": "0.0.29", "svelte": "^4.2.18" } } diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 842dc3a..2e2cd5d 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -1,12 +1,23 @@ import {throttle} from "throttle-debounce" -import {derived} from "svelte/store" +import {derived, writable} from "svelte/store" import type {Readable, Updater, Writable, Subscriber, Unsubscriber} from "svelte/store" -import {identity, batch, partition, first} from "@welshman/lib" +import {identity, getJson, setJson, batch, partition, first} from "@welshman/lib" import type {Maybe} from "@welshman/lib" import type {Repository} from "@welshman/util" import {matchFilters, getIdAndAddress, getIdFilters} from "@welshman/util" import type {Filter, TrustedEvent} from "@welshman/util" +// Generic store utils + +export const synced = (key: string, defaultValue: T) => { + const init = getJson(key) + const store = writable(init === null ? defaultValue : init) + + store.subscribe((value: T) => setJson(key, value)) + + return store +} + export const getter = (store: Readable) => { let value: T @@ -17,6 +28,12 @@ export const getter = (store: Readable) => { return () => value } +export function withGetter(store: Writable): Writable & {get: () => T} +export function withGetter(store: Readable): Readable & {get: () => T} +export function withGetter(store: Readable | Writable) { + return {...store, get: getter(store)} +} + type Start = (set: Subscriber) => Unsubscriber export const custom = (start: Start, opts: {throttle?: number} = {}) => { @@ -75,15 +92,11 @@ export const adapter = ({ update: (f: (x: Target) => Target) => store.update((x: Source) => backward(f(forward(x)))), }) -export function withGetter(store: Writable): Writable & {get: () => T} -export function withGetter(store: Readable): Readable & {get: () => T} -export function withGetter(store: Readable | Writable) { - return {...store, get: getter(store)} -} - export const throttled = (delay: number, store: Readable) => custom(set => store.subscribe(throttle(delay, set))) +// Event related stores + export const createEventStore = (repository: Repository): Writable => { let subs: Subscriber[] = [] diff --git a/packages/util/src/Profile.ts b/packages/util/src/Profile.ts index db06237..487c648 100644 --- a/packages/util/src/Profile.ts +++ b/packages/util/src/Profile.ts @@ -1,6 +1,7 @@ import {nip19} from "nostr-tools" import {ellipsize, parseJson} from "@welshman/lib" import {TrustedEvent} from "./Events" +import {getLnUrl} from './Zaps' import {PROFILE} from "./Kinds" export type Profile = { @@ -8,6 +9,7 @@ export type Profile = { nip05?: string lud06?: string lud16?: string + lnurl?: string about?: string banner?: string picture?: string @@ -23,25 +25,16 @@ export type PublishedProfile = Omit & { 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 makeProfile = (profile: Partial = {}): Profile => { + const address = profile.lud06 || profile.lud16 + const lnurl = address ? getLnUrl(address) : null -export const readProfile = (event: TrustedEvent) => { - const profile = parseJson(event.content) || {} - - return {...profile, event} as PublishedProfile + return lnurl ? {lnurl, ...profile} : profile } +export const readProfile = (event: TrustedEvent): PublishedProfile => + ({...makeProfile(parseJson(event.content) || {}), event}) + export const createProfile = ({event, ...profile}: Profile) => ({ kind: PROFILE, content: JSON.stringify(profile), diff --git a/packages/util/src/Zaps.ts b/packages/util/src/Zaps.ts index 71fca94..d866641 100644 --- a/packages/util/src/Zaps.ts +++ b/packages/util/src/Zaps.ts @@ -71,12 +71,12 @@ export const getLnUrl = (address: string) => { export type Zapper = { lnurl: string - pubkey: string, - callback: string - minSendable: number - maxSendable: number - nostrPubkey: string - allowsNostr: boolean + pubkey?: string, + callback?: string + minSendable?: number + maxSendable?: number + nostrPubkey?: string + allowsNostr?: boolean } export type Zap = {