Add app package
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
Generated
+50
-11
@@ -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",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
build
|
||||
normalize-url
|
||||
@@ -0,0 +1,3 @@
|
||||
# @welshman/store [](https://npmjs.com/package/@welshman/store)
|
||||
|
||||
Utilities for dealing with svelte stores when using welshman.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 = <T>({
|
||||
name,
|
||||
store,
|
||||
getKey,
|
||||
load,
|
||||
}: {
|
||||
name: string
|
||||
store: Readable<T[]>
|
||||
getKey: (item: T) => string
|
||||
load: (key: string, ...args: any) => Promise<any>
|
||||
}) => {
|
||||
const indexStore = withGetter(derived(store, $items => indexBy(getKey, $items)))
|
||||
const getItem = (key: string) => indexStore.get().get(key)
|
||||
const pending = new Map<string, Promise<Maybe<T>>>()
|
||||
|
||||
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<string>, ...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}
|
||||
}
|
||||
@@ -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<TrustedEvent>()
|
||||
|
||||
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<TrustedEvent[]>(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))
|
||||
@@ -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<PublishedList>(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<SubscribeRequest> = {}) => {
|
||||
const relays = getWriteRelayUrls(await loadRelaySelections(pubkey, hints))
|
||||
|
||||
return load({
|
||||
...request,
|
||||
relays: [...relays, ...hints],
|
||||
filters: [{kinds: [FOLLOWS], authors: [pubkey]}],
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import {writable} from 'svelte/store'
|
||||
import {assoc} from '@welshman/lib'
|
||||
import {withGetter} from '@welshman/store'
|
||||
|
||||
export const freshness = withGetter(writable<Record<string, number>>({}))
|
||||
|
||||
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<string, number>) =>
|
||||
freshness.update($freshness => {
|
||||
for (const [key, ts] of Object.entries(updates)) {
|
||||
$freshness[key] = ts
|
||||
}
|
||||
|
||||
return $freshness
|
||||
})
|
||||
@@ -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<Handle[]>([]))
|
||||
|
||||
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<string, Handle>()
|
||||
|
||||
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
|
||||
@@ -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),
|
||||
})
|
||||
@@ -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<PublishedList>(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<SubscribeRequest> = {}) => {
|
||||
const relays = getWriteRelayUrls(await loadRelaySelections(pubkey, hints))
|
||||
|
||||
return load({
|
||||
...request,
|
||||
relays: [...relays, ...hints],
|
||||
filters: [{kinds: [MUTES], authors: [pubkey]}],
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -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<Record<string, string>>({}))
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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<PublishedProfile>(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<SubscribeRequest> = {}) => {
|
||||
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)))
|
||||
@@ -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<SubscribeRequest> = {}) =>
|
||||
load({
|
||||
...request,
|
||||
relays,
|
||||
filters: [{kinds: [RELAYS], authors: [pubkey]}],
|
||||
}),
|
||||
})
|
||||
@@ -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<Relay[]>([]))
|
||||
|
||||
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<string, RelayProfile>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<string | null>("pubkey", null))
|
||||
|
||||
export const sessions = withGetter(synced<Record<string, Session>>("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
|
||||
}
|
||||
@@ -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<string, any>
|
||||
|
||||
export type IndexedDbAdapter = {
|
||||
keyPath: string
|
||||
store: Writable<Item[]>
|
||||
}
|
||||
|
||||
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<string, IndexedDbAdapter>) => {
|
||||
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)
|
||||
}
|
||||
@@ -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<string, PublishStatusData>
|
||||
|
||||
export type PublishStatusDataByUrlById = Record<string, PublishStatusDataByUrl>
|
||||
|
||||
export const publishStatusData = writable<PublishStatusDataByUrlById>({})
|
||||
|
||||
export type Thunk = {
|
||||
event: HashedEvent
|
||||
relays: string[]
|
||||
}
|
||||
|
||||
export type ThunkWithResolve = Thunk & {
|
||||
resolve: (data: PublishStatusDataByUrl) => void
|
||||
}
|
||||
|
||||
export const thunkWorker = new Worker<ThunkWithResolve>()
|
||||
|
||||
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<PublishStatusDataByUrl>(resolve => {
|
||||
thunkWorker.push({...thunk, resolve})
|
||||
repository.publish(thunk.event)
|
||||
})
|
||||
@@ -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<Topic[]>(setter => {
|
||||
const getTopics = () => {
|
||||
const topics = new Map<string, number>()
|
||||
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"]},
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
import Fuse from "fuse.js"
|
||||
import type {IFuseOptions, FuseResult} from "fuse.js"
|
||||
import {sortBy} from "@welshman/lib"
|
||||
|
||||
export type SearchOptions<V, T> = {
|
||||
getValue: (item: T) => V
|
||||
fuseOptions?: IFuseOptions<T>
|
||||
sortFn?: (items: FuseResult<T>) => any
|
||||
}
|
||||
|
||||
export type Search<V, T> = {
|
||||
options: T[]
|
||||
getValue: (item: T) => V
|
||||
getOption: (value: V) => T | undefined
|
||||
searchOptions: (term: string) => T[]
|
||||
searchValues: (term: string) => V[]
|
||||
}
|
||||
|
||||
export const createSearch = <V, T>(options: T[], opts: SearchOptions<V, T>): Search<V, T> => {
|
||||
const fuse = new Fuse(options, {...opts.fuseOptions, includeScore: true})
|
||||
const map = new Map<V, T>(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<T>)
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -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<Zapper[]>([]))
|
||||
|
||||
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<string, Zapper>()
|
||||
|
||||
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
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"targets": [
|
||||
{"extname": ".cjs", "module": "commonjs"},
|
||||
{"extname": ".mjs", "module": "esnext", "moduleResolution": "node"}
|
||||
],
|
||||
"projects": ["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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@welshman/lib": "0.0.15",
|
||||
"@welshman/util": "0.0.28"
|
||||
"@welshman/util": "0.0.29"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = <T>(f: () => T, onError?: (e: Error) => void): T | undefined => {
|
||||
try {
|
||||
const r = f()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export class Connection extends Emitter {
|
||||
new Promise<void>(resolve => {
|
||||
this.on('receive', (cxn: Connection, message: Message) => {
|
||||
if (message[0] === 'OK' && message[2]) {
|
||||
resolve()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@welshman/lib": "0.0.15",
|
||||
"@welshman/util": "0.0.28",
|
||||
"@welshman/util": "0.0.29",
|
||||
"svelte": "^4.2.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = <T>(key: string, defaultValue: T) => {
|
||||
const init = getJson(key)
|
||||
const store = writable<T>(init === null ? defaultValue : init)
|
||||
|
||||
store.subscribe((value: T) => setJson(key, value))
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
export const getter = <T>(store: Readable<T>) => {
|
||||
let value: T
|
||||
|
||||
@@ -17,6 +28,12 @@ export const getter = <T>(store: Readable<T>) => {
|
||||
return () => value
|
||||
}
|
||||
|
||||
export function withGetter<T>(store: Writable<T>): Writable<T> & {get: () => T}
|
||||
export function withGetter<T>(store: Readable<T>): Readable<T> & {get: () => T}
|
||||
export function withGetter<T>(store: Readable<T> | Writable<T>) {
|
||||
return {...store, get: getter<T>(store)}
|
||||
}
|
||||
|
||||
type Start<T> = (set: Subscriber<T>) => Unsubscriber
|
||||
|
||||
export const custom = <T>(start: Start<T>, opts: {throttle?: number} = {}) => {
|
||||
@@ -75,15 +92,11 @@ export const adapter = <Source, Target>({
|
||||
update: (f: (x: Target) => Target) => store.update((x: Source) => backward(f(forward(x)))),
|
||||
})
|
||||
|
||||
export function withGetter<T>(store: Writable<T>): Writable<T> & {get: () => T}
|
||||
export function withGetter<T>(store: Readable<T>): Readable<T> & {get: () => T}
|
||||
export function withGetter<T>(store: Readable<T> | Writable<T>) {
|
||||
return {...store, get: getter<T>(store)}
|
||||
}
|
||||
|
||||
export const throttled = <T>(delay: number, store: Readable<T>) =>
|
||||
custom(set => store.subscribe(throttle(delay, set)))
|
||||
|
||||
// Event related stores
|
||||
|
||||
export const createEventStore = (repository: Repository): Writable<TrustedEvent[]> => {
|
||||
let subs: Subscriber<TrustedEvent[]>[] = []
|
||||
|
||||
|
||||
@@ -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<Profile, "event"> & {
|
||||
export const isPublishedProfile = (profile: Profile): profile is PublishedProfile =>
|
||||
Boolean(profile.event)
|
||||
|
||||
export const makeProfile = (profile: Partial<Profile> = {}): Profile => ({
|
||||
name: "",
|
||||
nip05: "",
|
||||
lud06: "",
|
||||
lud16: "",
|
||||
about: "",
|
||||
banner: "",
|
||||
picture: "",
|
||||
website: "",
|
||||
display_name: "",
|
||||
...profile,
|
||||
})
|
||||
export const makeProfile = (profile: Partial<Profile> = {}): 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),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user