Add app package

This commit is contained in:
Jon Staab
2024-08-30 09:46:47 -07:00
parent e462ec2ada
commit 6e1ad713c3
34 changed files with 1193 additions and 48 deletions
+1 -1
View File
@@ -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",
+50 -11
View File
@@ -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",
+2
View File
@@ -0,0 +1,2 @@
build
normalize-url
+3
View File
@@ -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.
+44
View File
@@ -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"
}
}
+56
View File
@@ -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}
}
+41
View File
@@ -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))
+40
View File
@@ -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]}],
})
},
})
+22
View File
@@ -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
})
+65
View File
@@ -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
+27
View File
@@ -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),
})
+40
View File
@@ -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]}],
})
},
})
+25
View File
@@ -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)
}
+49
View File
@@ -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)))
+33
View File
@@ -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]}],
}),
})
+170
View File
@@ -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)
}
}
+82
View File
@@ -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
}
+121
View File
@@ -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)
}
+83
View File
@@ -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)
})
+41
View File
@@ -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"]},
}),
)
+77
View File
@@ -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))
}
+57
View File
@@ -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
}),
})
+7
View File
@@ -0,0 +1,7 @@
{
"targets": [
{"extname": ".cjs", "module": "commonjs"},
{"extname": ".mjs", "module": "esnext", "moduleResolution": "node"}
],
"projects": ["tsconfig.json"]
}
+11
View File
@@ -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"]
}
+1 -1
View File
@@ -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"
}
}
+1 -1
View File
@@ -32,6 +32,6 @@
},
"dependencies": {
"@welshman/lib": "0.0.15",
"@welshman/util": "0.0.28"
"@welshman/util": "0.0.29"
}
}
+4
View File
@@ -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()
+1 -1
View File
@@ -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"
}
+1 -1
View File
@@ -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()
}
})
})
+1 -1
View File
@@ -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"
}
}
+1 -1
View File
@@ -32,7 +32,7 @@
},
"dependencies": {
"@welshman/lib": "0.0.15",
"@welshman/util": "0.0.28",
"@welshman/util": "0.0.29",
"svelte": "^4.2.18"
}
}
+21 -8
View File
@@ -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[]>[] = []
+9 -16
View File
@@ -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),
+6 -6
View File
@@ -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 = {