Remove network, add load

This commit is contained in:
Jonathan Staab 2023-09-11 21:05:58 -07:00
parent ffc53f9210
commit dc87380904
27 changed files with 251 additions and 576 deletions

View File

@ -24,8 +24,9 @@
hasNewNotfications,
getUserRelayUrls,
searchableRelays,
Subscription,
} from "src/engine2"
import engine, {Keys, Directory, Network} from "src/app/engine"
import engine, {Keys, Directory} from "src/app/engine"
const {keyState, canUseGiftWrap} = Keys
const logoUrl = import.meta.env.VITE_LOGO_URL || "/images/logo.png"
@ -71,15 +72,15 @@
// If we have a query, search using nostr.band. If not, ask for random profiles.
// This allows us to populate results even if search isn't supported by forced urls
if (term.length > 2) {
Network.subscribe({
new Subscription({
relays: $searchableRelays,
filter: [{kinds: [0], search, limit: 10}],
filters: [{kinds: [0], search, limit: 10}],
timeout: 3000,
})
} else if (Directory.profiles.get().length < 50) {
Network.subscribe({
new Subscription({
relays: getUserRelayUrls("read"),
filter: [{kinds: [0], limit: 50}],
filters: [{kinds: [0], limit: 50}],
timeout: 3000,
})
}

View File

@ -47,8 +47,4 @@ export const Crypt = engine.Crypt
export const Directory = engine.Directory
export const Events = engine.Events
export const Keys = engine.Keys
export const Network = engine.Network
export const Nip04 = engine.Nip04
export const Nip05 = engine.Nip05
export const Nip28 = engine.Nip28
export const Nip44 = engine.Nip44

View File

@ -6,8 +6,15 @@
import PersonBadge from "src/app/shared/PersonBadge.svelte"
import ContentEditable from "src/partials/ContentEditable.svelte"
import Suggestions from "src/partials/Suggestions.svelte"
import {isFollowing, searchableRelays, getPubkeyHints} from "src/engine2"
import {Network, Directory} from "src/app/engine"
import {
people,
displayPerson,
Subscription,
isFollowing,
searchableRelays,
getPubkeyHints,
getPeopleSearch,
} from "src/engine2"
export let onSubmit
@ -28,16 +35,17 @@
},
}
const {searchProfiles} = Directory
const searchPeople = people.derived(getPeopleSearch)
const loadProfiles = debounce(500, search => {
const loadPeople = debounce(500, search => {
if (search.length > 2 && search.startsWith("@")) {
Network.subscribe({
const sub = new Subscription({
timeout: 3000,
relays: $searchableRelays,
filter: [{kinds: [0], search, limit: 10}],
onEvent: debounce(100, () => applySearch(getInfo().word)),
filters: [{kinds: [0], search, limit: 10}],
})
sub.on("event", () => applySearch(getInfo().word))
}
})
@ -46,7 +54,7 @@
if (word.length > 1 && word.startsWith("@")) {
const [followed, notFollowed] = partition(
p => isFollowing(p.pubkey).get(),
$searchProfiles(word.slice(1))
$searchPeople(word.slice(1))
)
results = followed.concat(notFollowed)
@ -64,7 +72,7 @@
return {selection, node, offset, word}
}
const autocomplete = ({profile = null, force = false} = {}) => {
const autocomplete = ({person = null, force = false} = {}) => {
let completed = false
const {selection, node, offset, word} = getInfo()
@ -98,8 +106,8 @@
}
// Mentions
if ((force || word.length > 1) && word.startsWith("@") && profile) {
annotate("@", Directory.displayProfile(profile).trim(), pubkeyEncoder.encode(profile.pubkey))
if ((force || word.length > 1) && word.startsWith("@") && person) {
annotate("@", displayPerson(person).trim(), pubkeyEncoder.encode(person.pubkey))
}
// Topics
@ -130,7 +138,7 @@
// Enter adds a newline, so do it on key down
if (["Enter"].includes(e.code)) {
autocomplete({profile: suggestions.get()})
autocomplete({person: suggestions.get()})
}
// Only autocomplete topics on space
@ -145,11 +153,11 @@
const {word} = getInfo()
// Populate search data
loadProfiles(word)
loadPeople(word)
applySearch(word)
if (["Tab"].includes(e.code)) {
autocomplete({profile: suggestions.get()})
autocomplete({person: suggestions.get()})
}
if (["Escape", "Space"].includes(e.code)) {
@ -167,7 +175,7 @@
dispatch("keyup", e)
}
export const mention = profile => {
export const mention = person => {
const input = contenteditable.getInput()
const selection = window.getSelection()
const textNode = document.createTextNode("@")
@ -179,7 +187,7 @@
selection.getRangeAt(0).insertNode(spaceNode)
selection.collapse(input, 1)
autocomplete({profile, force: true})
autocomplete({person, force: true})
}
const createNewLines = (n = 1) => {
@ -258,7 +266,7 @@
<slot name="addon" />
</div>
<Suggestions bind:this={suggestions} select={profile => autocomplete({profile})}>
<Suggestions bind:this={suggestions} select={person => autocomplete({person})}>
<div slot="item" let:item>
<PersonBadge inert pubkey={item.pubkey} />
</div>

View File

@ -7,27 +7,27 @@
import Card from "src/partials/Card.svelte"
import Content from "src/partials/Content.svelte"
import ImageCircle from "src/partials/ImageCircle.svelte"
import {Nip28} from "src/app/engine"
import {channels} from "src/engine2"
export let note
const {name, picture, about} = tryJson(() => JSON.parse(note.content))
const channel = Nip28.channels
const channel = channels
.key(note.id)
.derived(defaultTo({id: note.id, name, picture, about}))
.derived(defaultTo({id: note.id, meta: {name, picture, about}}))
const noteId = nip19.noteEncode(note.kind === 40 ? note.id : Tags.from(note).getMeta("e"))
</script>
<Card interactive invertColors on:click={() => navigate(`/chat/${noteId}`)}>
<Content>
<div class="flex items-center gap-2">
{#if $channel.picture}
<ImageCircle size={10} src={$channel.picture} />
{#if $channel.meta?.picture}
<ImageCircle size={10} src={$channel.meta?.picture} />
{/if}
<h3 class="staatliches text-2xl">{$channel.name}</h3>
<h3 class="staatliches text-2xl">{$channel.meta?.name}</h3>
</div>
{#if $channel.about}
<p>{$channel.about}</p>
{#if $channel.meta?.about}
<p>{$channel.meta?.about}</p>
{/if}
</Content>
</Card>

View File

@ -9,8 +9,8 @@
import Card from "src/partials/Card.svelte"
import Spinner from "src/partials/Spinner.svelte"
import PersonCircle from "src/app/shared/PersonCircle.svelte"
import {getSetting, isEventMuted, getEventHints, mergeHints} from "src/engine2"
import {Directory, Network} from "src/app/engine"
import {getSetting, Subscription, isEventMuted, getEventHints, mergeHints} from "src/engine2"
import {Directory} from "src/app/engine"
export let note
export let value
@ -29,23 +29,24 @@
getEventHints(getSetting("relay_limit"), note),
])
const filter = (
const filters = [
id
? {ids: [id]}
: filterVals(xs => xs.length > 0, {
"#d": [identifier],
kinds: [kind],
authors: [pubkey],
})
) as Filter
}),
] as Filter[]
const onEvent = event => {
const sub = new Subscription({timeout: 30000, relays, filters})
sub.on("event", event => {
loading = false
muted = isEventMuted(event).get()
quote = event
}
const sub = Network.subscribe({timeout: 30000, relays, filter, onEvent})
sub.close()
})
const openQuote = e => {
const noteId = id || quote?.id

View File

@ -1,13 +1,13 @@
<script lang="ts">
import {nip19} from "nostr-tools"
import {Nip05} from "src/app/engine"
import {people, displayHandle} from "src/engine2"
export let pubkey
const npub = nip19.npubEncode(pubkey)
const handle = Nip05.handles.key(pubkey)
const person = people.key(pubkey)
</script>
<div class="overflow-hidden overflow-ellipsis opacity-75">
{$handle ? Nip05.displayHandle($handle) : npub}
{$person?.handle ? displayHandle($person.handle) : npub}
</div>

View File

@ -5,9 +5,8 @@
import Content from "src/partials/Content.svelte"
import Spinner from "src/partials/Spinner.svelte"
import PersonSummary from "src/app/shared/PersonSummary.svelte"
import {loadPubkeys} from "src/engine2"
import {getSetting, getPubkeyHints, follows} from "src/engine2"
import {Network} from "src/app/engine"
import type {Event} from "src/engine2"
import {Subscription, getSetting, loadPubkeys, getPubkeyHints, follows} from "src/engine2"
export let type
export let pubkey
@ -18,17 +17,21 @@
if (type === "follows") {
pubkeys = $follows
} else {
const sub = Network.subscribe({
const sub = new Subscription({
relays: getPubkeyHints(getSetting("relay_limit"), pubkey, "read"),
filter: {kinds: [3], "#p": [pubkey]},
onEvent: batch(500, events => {
filters: [{kinds: [3], "#p": [pubkey]}],
})
sub.on(
"event",
batch(500, (events: Event[]) => {
const newPubkeys = pluck("pubkey", events)
loadPubkeys(newPubkeys)
pubkeys = uniq(pubkeys.concat(newPubkeys))
}),
})
})
)
return sub.close
}

View File

@ -5,8 +5,9 @@
import {tweened} from "svelte/motion"
import {numberFmt} from "src/util/misc"
import {modal} from "src/partials/state"
import {people, getPubkeyHints} from "src/engine2"
import {Keys, Network} from "src/app/engine"
import type {Event} from "src/engine2"
import {people, count, Subscription, getPubkeyHints} from "src/engine2"
import {Keys} from "src/app/engine"
export let pubkey
@ -25,26 +26,30 @@
canLoadFollowers = false
// Get our followers count
const count = await Network.count({kinds: [3], "#p": [pubkey]})
const total = await count([{kinds: [3], "#p": [pubkey]}])
if (count) {
followersCount.set(count)
if (total) {
followersCount.set(total)
} else {
const followers = new Set()
sub = Network.subscribe({
sub = new Subscription({
timeout: 30_000,
shouldProcess: false,
ephemeral: true,
relays: getPubkeyHints(3, Keys.pubkey.get(), "read"),
filter: [{kinds: [3], "#p": [pubkey]}],
onEvent: batch(300, events => {
filters: [{kinds: [3], "#p": [pubkey]}],
})
sub.on(
"event",
batch(300, (events: Event[]) => {
for (const e of events) {
followers.add(e.pubkey)
}
followersCount.set(followers.size)
}),
})
})
)
}
}

View File

@ -1,7 +1,7 @@
<script lang="ts">
import {poll} from "hurdak"
import {onMount} from "svelte"
import {Network} from "src/app/engine"
import {pool} from "src/engine2"
export let relay
@ -11,7 +11,7 @@
onMount(() => {
return poll(3000, () => {
const cxn = Network.pool.get(relay.url, {autoConnect: false})
const cxn = pool.get(relay.url, {autoConnect: false})
if (cxn) {
description = cxn.meta.getDescription()

View File

@ -4,15 +4,25 @@ import Bugsnag from "@bugsnag/js"
import {nip19} from "nostr-tools"
import {navigate} from "svelte-routing"
import {writable} from "svelte/store"
import {whereEq, omit, filter, pluck, sortBy, slice} from "ramda"
import {omit, path, filter, pluck, sortBy, slice} from "ramda"
import {hash, union, sleep, doPipe, shuffle} from "hurdak"
import {warn} from "src/util/logger"
import {now} from "src/util/misc"
import {userKinds, noteKinds} from "src/util/nostr"
import {modal, toast} from "src/partials/state"
import type {Event} from "src/engine2"
import {loadPubkeys, follows, network, getUserRelayUrls, getSetting, dufflepud} from "src/engine2"
import {Events, Nip28, Env, Network, Keys} from "src/app/engine"
import {
pool,
loadPubkeys,
channels,
follows,
network,
Subscription,
getUserRelayUrls,
getSetting,
dufflepud,
} from "src/engine2"
import {Events, Env, Keys} from "src/app/engine"
// Routing
@ -85,7 +95,7 @@ setInterval(() => {
// Prune connections we haven't used in a while, clear errors periodically,
// and keep track of slow connections
for (const [url, connection] of Network.pool.data.entries()) {
for (const [url, connection] of pool.data.entries()) {
if (connection.meta.last_activity < now() - 60) {
connection.disconnect()
} else if (connection.lastError < Date.now() - 10_000) {
@ -107,7 +117,7 @@ let timeout
export const listenForNotifications = async () => {
const pubkey = Keys.pubkey.get()
const channelIds = pluck("id", Nip28.channels.get().filter(whereEq({joined: true})))
const channelIds = pluck("id", channels.get().filter(path(["nip28", "joined"])))
const eventIds: string[] = doPipe(Events.cache.get(), [
filter((e: Event) => noteKinds.includes(e.kind)),
@ -119,9 +129,9 @@ export const listenForNotifications = async () => {
// Only grab one event from each category/relay so we have enough to show
// the notification badges, but load the details lazily
listener?.close()
listener = Network.subscribe({
listener = new Subscription({
relays: getUserRelayUrls("read"),
filter: [
filters: [
// Messages
{kinds: [4], authors: [pubkey], limit: 1},
{kinds: [4], "#p": [pubkey], limit: 1},

View File

@ -1,6 +1,6 @@
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {defaultTo, filter, whereEq} from "ramda"
import {assoc} from "ramda"
import {formatTimestamp} from "src/util/misc"
import {toHex} from "src/util/nostr"
import {modal} from "src/partials/state"
@ -10,6 +10,7 @@
import PersonBadgeSmall from "src/app/shared/PersonBadgeSmall.svelte"
import NoteContent from "src/app/shared/NoteContent.svelte"
import {
channels,
imgproxy,
publishNip28Message,
joinNip28Channel,
@ -17,23 +18,20 @@
loadNip28Messages,
publishNip28ChannelChecked,
} from "src/engine2"
import {Nip28, Keys} from "src/app/engine"
import {Keys} from "src/app/engine"
export let entity
const id = toHex(entity)
const channel = Nip28.channels.key(id).derived(defaultTo({id}))
const messages = Nip28.messages.derived(filter(whereEq({channel: id})))
const channel = channels.key(id)
publishNip28ChannelChecked(id)
const join = () => joinNip28Channel($channel.id)
const join = () => joinNip28Channel(id)
const leave = () => leaveNip28Channel($channel.id)
const leave = () => leaveNip28Channel(id)
const edit = () => {
modal.push({type: "chat/edit", channel: $channel})
}
const edit = () => modal.push({type: "chat/edit", channel: $channel})
const sendMessage = content => publishNip28Message(id, content).result
@ -46,35 +44,36 @@
onDestroy(() => {
publishNip28ChannelChecked(id)
if (!$channel.joined) {
Nip28.messages.reject(m => m.channel === id)
// Save on memory by deleting messages we don't care about
if (!$channel?.nip28?.joined) {
channel.update(assoc("messages", []))
}
})
$: picture = imgproxy($channel.picture, {w: 96, h: 96})
$: picture = imgproxy($channel?.meta?.picture, {w: 96, h: 96})
document.title = $channel.name || "Coracle Chat"
document.title = $channel?.meta?.name || "Coracle Chat"
</script>
<Channel {messages} {sendMessage}>
<Channel messages={$channel?.messages || []} {sendMessage}>
<div slot="header" class="flex h-16 items-start gap-4 overflow-hidden p-2">
<div class="flex items-center gap-4 pt-1">
<Anchor type="unstyled" class="fa fa-arrow-left cursor-pointer text-2xl" href="/chat" />
<ImageCircle size={10} src={picture} />
</div>
<div class="flex h-12 flex-grow flex-col overflow-hidden pt-px">
<div class="font-bold">{$channel.name || ""}</div>
<div>{$channel.about || ""}</div>
<div class="font-bold">{$channel?.meta?.name || ""}</div>
<div>{$channel?.meta?.about || ""}</div>
</div>
<div class="flex h-12 flex-col pt-px">
<div class="flex w-full items-center justify-between">
<div class="flex gap-2">
{#if $channel.pubkey === Keys.pubkey.get()}
{#if $channel?.nip28?.owner === Keys.pubkey.get()}
<button class="cursor-pointer text-sm" on:click={edit}>
<i class="fa-solid fa-edit" /> Edit
</button>
{/if}
{#if $channel.joined}
{#if $channel?.nip28?.joined}
<Anchor theme="button" killEvent class="flex items-center gap-2" on:click={leave}>
<i class="fa fa-right-from-bracket" />
<span>Leave</span>

View File

@ -11,8 +11,8 @@
import Content from "src/partials/Content.svelte"
import NoteById from "src/app/shared/NoteById.svelte"
import PersonBadgeSmall from "src/app/shared/PersonBadgeSmall.svelte"
import {getUserRelayUrls, follows} from "src/engine2"
import engine, {Network, Keys} from "src/app/engine"
import {getUserRelayUrls, follows, Subscription} from "src/engine2"
import engine, {Keys} from "src/app/engine"
type LabelGroup = {
label: string
@ -63,13 +63,15 @@
const showGroup = ({label, ids, hints}) => modal.push({type: "label/detail", label, ids, hints})
onMount(() => {
const sub = Network.subscribe({
const sub = new Subscription({
relays: getUserRelayUrls("read"),
filter: {
kinds: [1985],
"#L": ["#t", "ugc"],
authors: $follows.concat($pubkey),
},
filters: [
{
kinds: [1985],
"#L": ["#t", "ugc"],
authors: $follows.concat($pubkey),
},
],
})
return () => sub.close()

View File

@ -12,8 +12,8 @@
import Anchor from "src/partials/Anchor.svelte"
import Modal from "src/partials/Modal.svelte"
import RelayCard from "src/app/shared/RelayCard.svelte"
import {relays, loadPubkeys, getUserRelayUrls} from "src/engine2"
import {Env, Keys, Network} from "src/app/engine"
import {relays, pool, loadPubkeys, getUserRelayUrls} from "src/engine2"
import {Env, Keys} from "src/app/engine"
import {loadAppData} from "src/app/state"
const pubkey = Keys.pubkey.get()
@ -75,7 +75,7 @@
navigate("/notes")
} else {
Network.pool.remove(relay.url)
pool.remove(relay.url)
}
})
}

View File

@ -16,8 +16,8 @@
import RelayCard from "src/app/shared/RelayCard.svelte"
import NoteContent from "src/app/shared/NoteContent.svelte"
import RelaySearch from "src/app/shared/RelaySearch.svelte"
import {publishNote, displayRelay, getUserRelayUrls, mention} from "src/engine2"
import {Directory, Network, Keys} from "src/app/engine"
import {Publisher, publishNote, displayRelay, getUserRelayUrls, mention} from "src/engine2"
import {Directory, Keys} from "src/app/engine"
import {modal} from "src/partials/state"
import {toastProgress} from "src/app/state"
@ -45,7 +45,7 @@
tags.push(mention(quote.pubkey))
// Re-broadcast the note we're quoting
Network.publish({relays: $relays, event: quote})
Publisher.publish({relays: $relays, event: quote})
}
publishNote(content, tags, $relays).on("progress", toastProgress)

View File

@ -4,12 +4,11 @@
import Content from "src/partials/Content.svelte"
import CopyValue from "src/partials/CopyValue.svelte"
import RelayCard from "src/app/shared/RelayCard.svelte"
import {getPubkeyHints} from "src/engine2"
import {Nip05} from "src/app/engine"
import {getPubkeyHints, displayHandle, people} from "src/engine2"
export let pubkey
const handle = Nip05.getHandle(pubkey)
const person = people.key(pubkey)
const relays = getPubkeyHints(3, pubkey, "write")
const nprofile = nip19.nprofileEncode({pubkey, relays})
</script>
@ -19,12 +18,12 @@
<h1 class="staatliches text-2xl">Details</h1>
<CopyValue label="Link" value={nprofile} />
<CopyValue label="Public Key" encode={nip19.npubEncode} value={pubkey} />
{#if handle}
{@const display = Nip05.displayHandle(handle)}
{#if $person?.handle}
{@const display = displayHandle($person.handle)}
<CopyValue label="Nostr Address" value={display} />
<Content size="inherit" gap="gap-2">
<strong>Nostr Address Relays</strong>
{#each handle.profile.relays || [] as url}
{#each $person.handle.profile.relays || [] as url}
<RelayCard relay={{url}} />
{:else}
<p class="flex gap-2 items-center">

View File

@ -4,12 +4,7 @@ import {Crypt} from "./components/Crypt"
import {Directory} from "./components/Directory"
import {Events} from "./components/Events"
import {Keys} from "./components/Keys"
import {Network} from "./components/Network"
import {Nip04} from "./components/Nip04"
import {Nip05} from "./components/Nip05"
import {Nip28} from "./components/Nip28"
import {Nip44} from "./components/Nip44"
import {Settings} from "./components/Settings"
export class Engine {
Env: Env
@ -18,12 +13,7 @@ export class Engine {
Directory = new Directory()
Events = new Events()
Keys = new Keys()
Network = new Network()
Nip04 = new Nip04()
Nip05 = new Nip05()
Nip28 = new Nip28()
Nip44 = new Nip44()
Settings = new Settings()
constructor(Env: Env) {
this.Env = Env

View File

@ -1,215 +0,0 @@
import {omit} from "ramda"
import {Pool, Plex, Relays, Executor} from "paravel"
import {noop, ensurePlural, union, difference} from "hurdak"
import {warn, error, info} from "src/util/logger"
import {normalizeRelayUrl} from "src/util/nostr"
import type {Event, Filter} from "src/engine/types"
import type {Engine} from "src/engine/Engine"
import {Subscription} from "src/engine/util/Subscription"
export type Progress = {
event: Event
succeeded: Set<string>
failed: Set<string>
timeouts: Set<string>
completed: Set<string>
pending: Set<string>
}
export type PublishOpts = {
event: Event
relays: string[]
onProgress?: (p: Progress) => void
timeout?: number
verb?: string
}
export type SubscribeOpts = {
relays: string[]
filter: Filter[] | Filter
onEvent?: (event: Event) => void
onEose?: (url: string) => void
onClose?: () => void
timeout?: number
shouldProcess?: boolean
}
export class Network {
engine: Engine
pool = new Pool()
authHandler: (url: string, challenge: string) => void
relayIsLowQuality = (url: string) => this.pool.get(url, {autoConnect: false})?.meta?.quality < 0.6
getUrls = (relays: string[]) => {
if (this.engine.Env.FORCE_RELAYS?.length > 0) {
return this.engine.Env.FORCE_RELAYS
}
if (relays.length === 0) {
error(`Attempted to connect to zero urls`)
}
const urls = new Set(relays.map(normalizeRelayUrl))
if (urls.size !== relays.length) {
warn(`Attempted to connect to non-unique relays`)
}
return Array.from(urls)
}
getExecutor = (urls: string[], {bypassBoot = false} = {}) => {
let target
const muxUrl = this.engine.Settings.getSetting("multiplextr_url")
// Try to use our multiplexer, but if it fails to connect fall back to relays. If
// we're only connecting to a single relay, just do it directly, unless we already
// have a connection to the multiplexer open, in which case we're probably doing
// AUTH with a single relay.
if (muxUrl && (urls.length > 1 || this.pool.has(muxUrl))) {
const connection = this.pool.get(muxUrl)
if (connection.socket.isHealthy()) {
target = new Plex(urls, connection)
}
}
if (!target) {
target = new Relays(urls.map(url => this.pool.get(url)))
}
const executor = new Executor(target)
if (this.authHandler) {
executor.handleAuth({onAuth: this.authHandler, onOk: noop})
}
return executor
}
publish = ({
relays,
event,
onProgress,
timeout = 3000,
verb = "EVENT",
}: PublishOpts): Promise<Progress> => {
const urls = this.getUrls(relays)
const executor = this.getExecutor(urls, {bypassBoot: verb === "AUTH"})
if (event.wrap) {
throw new Error("Can't publish unwrapped event")
}
info(`Publishing to ${urls.length} relays`, event, urls)
return new Promise(resolve => {
const timeouts = new Set<string>()
const succeeded = new Set<string>()
const failed = new Set<string>()
const getProgress = () => {
const completed = union(timeouts, succeeded, failed)
const pending = difference(new Set(urls), completed)
return {event, succeeded, failed, timeouts, completed, pending}
}
const attemptToResolve = () => {
const progress = getProgress()
if (progress.pending.size === 0) {
resolve(progress)
sub.unsubscribe()
executor.target.cleanup()
}
onProgress?.(progress)
}
setTimeout(() => {
for (const url of urls) {
if (!succeeded.has(url) && !failed.has(url)) {
timeouts.add(url)
}
}
attemptToResolve()
}, timeout)
const sub = executor.publish(omit(["seen_on"], event), {
verb,
onOk: (url: string) => {
succeeded.add(url)
timeouts.delete(url)
failed.delete(url)
attemptToResolve()
},
onError: (url: string) => {
failed.add(url)
timeouts.delete(url)
attemptToResolve()
},
})
// Report progress to start
attemptToResolve()
})
}
subscribe = ({
relays,
filter,
onEose,
onEvent,
onClose,
timeout,
shouldProcess = true,
}: SubscribeOpts) => {
const urls = this.getUrls(relays)
const subscription = new Subscription({
executor: this.getExecutor(urls),
filters: ensurePlural(filter),
relays: urls,
timeout,
})
info(`Starting subscription with ${urls.length} relays`, {filter, urls})
if (onEose) subscription.on("eose", onEose)
if (onClose) subscription.on("close", onClose)
subscription.on("event", event => {
if (shouldProcess) {
this.engine.Events.queue.push(event)
}
onEvent?.(event)
})
return subscription
}
count = async (filter: Filter | Filter[]) => {
const filters = ensurePlural(filter)
const executor = this.getExecutor(this.getUrls(this.engine.Env.COUNT_RELAYS))
return new Promise(resolve => {
const sub = executor.count(filters, {
onCount: (url: string, {count}: {count: number}) => resolve(count),
})
setTimeout(() => {
resolve(0)
sub.unsubscribe()
executor.target.cleanup()
}, 3000)
})
}
initialize(engine: Engine) {
this.engine = engine
}
}

View File

@ -1,46 +0,0 @@
import {last} from "ramda"
import {tryFunc, Fetch} from "hurdak"
import {now, tryJson} from "src/util/misc"
import type {Handle} from "src/engine/types"
import type {Engine} from "src/engine/Engine"
import {collection} from "src/engine/util/store"
export class Nip05 {
handles = collection<Handle>("pubkey")
getHandle = (pubkey: string) => this.handles.key(pubkey).get()
displayHandle = (handle: Handle) =>
handle.address.startsWith("_@") ? last(handle.address.split("@")) : handle.address
initialize(engine: Engine) {
engine.Events.addHandler(0, e => {
tryJson(async () => {
const kind0 = JSON.parse(e.content)
const handle = this.handles.key(e.pubkey)
if (!kind0.nip05 || e.created_at < (handle.get()?.created_at || 0)) {
return
}
const body = {handle: kind0.nip05}
const url = engine.Settings.dufflepud("handle/info")
const profile = (await tryFunc(() => Fetch.postJson(url, body))) as null | {
pubkey: string
}
if (profile?.pubkey !== e.pubkey) {
return
}
handle.set({
profile: profile,
pubkey: e.pubkey,
address: kind0.nip05,
created_at: e.created_at,
updated_at: now(),
})
})
})
}
}

View File

@ -1,81 +0,0 @@
import {switcherFn, tryFunc} from "hurdak"
import {tryJson} from "src/util/misc"
import {LRUCache} from "src/util/lruCache"
import type {Engine} from "src/engine/Engine"
import * as nip44 from "src/engine/util/nip44"
export type Nip44Opts = {
pk?: string
sk?: string
}
const sharedSecretCache = new LRUCache<string, Uint8Array>(100)
// Deriving shared secret is an expensive computation
function getSharedSecret(sk: string, pk: string) {
const cacheKey = `${sk.slice(0, 8)}:${pk}`
let result = sharedSecretCache.get(cacheKey)
if (!result) {
result = nip44.getSharedSecret(sk, pk)
sharedSecretCache.set(cacheKey, result)
}
return result
}
export class Nip44 {
engine: Engine
async encrypt(message: string, {pk, sk}: Nip44Opts = {}) {
if (!pk) {
pk = this.engine.Keys.pubkey.get()
}
if (sk) {
return nip44.encrypt(getSharedSecret(sk, pk), message)
}
return switcherFn(this.engine.Keys.method.get(), {
privkey: () => {
const privkey = this.engine.Keys.privkey.get() as string
const secret = getSharedSecret(privkey, pk)
return nip44.encrypt(secret, message)
},
})
}
async decrypt(message, {pk, sk}: Nip44Opts = {}) {
if (!pk) {
pk = this.engine.Keys.pubkey.get()
}
if (sk) {
return nip44.decrypt(getSharedSecret(sk, pk), message)
}
return switcherFn(this.engine.Keys.method.get(), {
privkey: () => {
const privkey = this.engine.Keys.privkey.get() as string
const secret = getSharedSecret(privkey, pk)
return tryFunc(() => nip44.decrypt(secret, message)) || `<Failed to decrypt message>`
},
})
}
async encryptJson(data: any, opts?: Nip44Opts) {
return this.encrypt(JSON.stringify(data), opts)
}
async decryptJson(data: string, opts?: Nip44Opts) {
return tryJson(async () => JSON.parse(await this.decrypt(data, opts)))
}
initialize(engine: Engine) {
this.engine = engine
}
}

View File

@ -1,60 +0,0 @@
import {Tags, appDataKeys} from "src/util/nostr"
import {writable} from "src/engine/util/store"
import type {Writable} from "src/engine/util/store"
import type {Engine} from "src/engine/Engine"
export class Settings {
engine: Engine
settings: Writable<Record<string, any>>
getSetting = (k: string) => this.settings.get()[k]
imgproxy = (url: string, {w = 640, h = 1024} = {}) => {
const base = this.getSetting("imgproxy_url")
if (!url || url.match("gif$")) {
return url
}
try {
return base && url ? `${base}/x/s:${w}:${h}/${btoa(url)}` : url
} catch (e) {
return url
}
}
dufflepud = (path: string) => `${this.getSetting("dufflepud_url")}/${path}`
initialize(engine: Engine) {
this.engine = engine
this.settings = writable<Record<string, any>>({
last_updated: 0,
relay_limit: 10,
default_zap: 21,
show_media: true,
report_analytics: true,
imgproxy_url: engine.Env.IMGPROXY_URL,
dufflepud_url: engine.Env.DUFFLEPUD_URL,
multiplextr_url: engine.Env.MULTIPLEXTR_URL,
})
engine.Events.addHandler(30078, async e => {
if (
engine.Keys.canSign.get() &&
Tags.from(e).getMeta("d") === appDataKeys.USER_SETTINGS &&
e.created_at > this.getSetting("last_updated")
) {
const updates = await engine.Crypt.decryptJson(e.content)
if (updates) {
this.settings.update($settings => ({
...$settings,
...updates,
last_updated: e.created_at,
}))
}
}
})
}
}

View File

@ -6,8 +6,4 @@ export {Crypt} from "./components/Crypt"
export {Directory} from "./components/Directory"
export {Events} from "./components/Events"
export {Keys} from "./components/Keys"
export {Network} from "./components/Network"
export {Nip04} from "./components/Nip04"
export {Nip05} from "./components/Nip05"
export {Nip28} from "./components/Nip28"
export {Nip44} from "./components/Nip44"

View File

@ -1,43 +0,0 @@
import {base64} from "@scure/base"
import {randomBytes} from "@noble/hashes/utils"
import {secp256k1} from "@noble/curves/secp256k1"
import {sha256} from "@noble/hashes/sha256"
import {xchacha20} from "@noble/ciphers/chacha"
export const utf8Decoder = new TextDecoder()
export const utf8Encoder = new TextEncoder()
export const getSharedSecret = (privkey: string, pubkey: string): Uint8Array =>
sha256(secp256k1.getSharedSecret(privkey, "02" + pubkey).subarray(1, 33))
export function encrypt(key: Uint8Array, text: string, v = 1) {
if (v !== 1) {
throw new Error("NIP44: unknown encryption version")
}
const nonce = randomBytes(24)
const plaintext = utf8Encoder.encode(text)
const ciphertext = xchacha20(key, nonce, plaintext)
const payload = new Uint8Array(25 + ciphertext.length)
payload.set([v], 0)
payload.set(nonce, 1)
payload.set(ciphertext, 25)
return base64.encode(payload)
}
export function decrypt(key: Uint8Array, payload: string) {
const data = base64.decode(payload)
if (data[0] !== 1) {
throw new Error(`NIP44: unknown encryption version: ${data[0]}`)
}
const nonce = data.slice(1, 25)
const ciphertext = data.slice(25)
const plaintext = xchacha20(key, nonce, ciphertext)
return utf8Decoder.decode(plaintext)
}

View File

@ -21,11 +21,9 @@ export const displayRelay = ({url}: Relay) => last(url.split("://"))
export const searchRelays = relays.derived($relays => fuzzy($relays, {keys: ["url"]}))
export const searchableRelays = relays.derived($relays => {
const searchableRelayUrls = $relays
.filter(r => (r.info?.supported_nips || []).includes(50))
.map(prop("url"))
const urls = $relays.filter(r => (r.info?.supported_nips || []).includes(50)).map(prop("url"))
return uniq(env.get().SEARCH_RELAYS.concat(searchableRelayUrls)).slice(0, 8) as string[]
return uniq(env.get().SEARCH_RELAYS.concat(urls)).slice(0, 8) as string[]
})
export const getPubkeyRelays = (pubkey: string, mode: string = null) => {

View File

@ -0,0 +1,18 @@
import type {Filter} from "src/engine2/model"
import {getUrls, getExecutor, searchableRelays} from "src/engine2/queries"
export const count = async (filters: Filter[]) => {
const executor = getExecutor(getUrls(searchableRelays.get()))
return new Promise(resolve => {
const sub = executor.count(filters, {
onCount: (url: string, {count}: {count: number}) => resolve(count),
})
setTimeout(() => {
resolve(0)
sub.unsubscribe()
executor.target.cleanup()
}, 3000)
})
}

View File

@ -2,6 +2,8 @@ export * from "./context"
export * from "./cursor"
export * from "./feed"
export * from "./pubkeys"
export * from "./count"
export * from "./load"
export * from "./subscription"
export * from "./thread"
export * from "./nip04"

View File

@ -0,0 +1,76 @@
import {matchFilters} from "nostr-tools"
import {prop, groupBy, uniq} from "ramda"
import {batch} from "hurdak"
import {subscribe} from "./subscription"
import type {Event, Filter} from "src/engine2/model"
export type LoadOpts = {
relays: string[]
filters: Filter[]
onEvent?: (e: Event) => void
onEose?: (url: string) => void
onClose?: (events: Event[]) => void
}
export const calculateGroup = ({since, until, ...filter}: Filter) => {
const group = Object.keys(filter)
if (since) {
group.push(`since:${since}`)
}
if (until) {
group.push(`until:${until}`)
}
return group.sort().join("-")
}
export const combineFilters = filters => {
const result = []
for (const group of Object.values(groupBy(calculateGroup, filters))) {
const newFilter = {}
for (const k of Object.keys(group[0])) {
newFilter[k] = uniq(group.flatMap(prop(k)))
}
result.push(newFilter)
}
return result
}
export const load = batch(500, (requests: LoadOpts[]) => {
const relays = uniq(requests.flatMap(prop("relays")))
const filters = combineFilters(requests.flatMap(prop("filters")))
const sub = subscribe({relays, filters, timeout: 3000})
sub.on("event", (e: Event) => {
for (const req of requests) {
if (!req.onEvent) {
continue
}
if (matchFilters(req.filters, e)) {
req.onEvent(e)
}
}
})
sub.on("eose", url => {
for (const req of requests) {
req.onEose?.(url)
}
})
sub.on("close", events => {
for (const req of requests) {
req.onClose?.(events)
}
})
return sub
})

View File

@ -7,7 +7,7 @@ import type {Event, Filter} from "src/engine2/model"
import {getUrls, getExecutor} from "src/engine2/queries"
import {projections} from "src/engine2/projections"
type SubscriptionOpts = {
export type SubscriptionOpts = {
relays: string[]
filters: Filter[]
timeout?: number
@ -102,3 +102,19 @@ export class Subscription extends EventEmitter {
}
}
}
export type SubscribeOpts = SubscriptionOpts & {
onEvent?: (e: Event) => void
onEose?: (url: string) => void
onClose?: (events: Event[]) => void
}
export const subscribe = (opts: SubscribeOpts) => {
const sub = new Subscription(opts)
if (opts.onEvent) sub.on("event", opts.onEvent)
if (opts.onEose) sub.on("eose", opts.onEose)
if (opts.onClose) sub.on("close", opts.onClose)
return sub
}