Add app package
This commit is contained in:
@@ -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
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user