Refactor again

This commit is contained in:
Jonathan Staab 2023-07-11 16:42:46 -07:00
parent 8428c179c6
commit faccf16f2e
40 changed files with 782 additions and 604 deletions

View File

@ -1,6 +1,7 @@
# Current
- [ ] Refactor
- [ ] Inject env into system
- [ ] Delete messages when leaving a room
- [ ] Add pagination to chat/dms
- [ ] Fix scrolling to to when modal is open

View File

@ -2,8 +2,7 @@ import {max, mergeLeft, fromPairs, sortBy, assoc, uniqBy, prop, propEq, groupBy,
import {findReplyId} from "src/util/nostr"
import {chunk, ensurePlural} from "hurdak/lib/hurdak"
import {batch, now, timedelta} from "src/util/misc"
import {PubkeyLoader} from "src/system"
import system, {ENABLE_ZAPS, user, routing, network} from "src/app/system"
import {ENABLE_ZAPS, user, routing, pubkeyLoader, network} from "src/app/system"
class Cursor {
relays: string[]
@ -153,7 +152,7 @@ const streamContext = ({notes, onChunk, maxDepth = 2}) => {
const pubkeys = pluck("pubkey", events)
// Load any people we should know about
new PubkeyLoader(system).loadPubkeys(pubkeys)
pubkeyLoader.loadPubkeys(pubkeys)
// Load data prior to now for our new ids
chunk(256, newIds).forEach(ids => {

View File

@ -56,7 +56,7 @@
seenChallenges.add(challenge)
const rawEvent = system.builder.authenticate(url, challenge)
const [event] = await system.outbox.publish(rawEvent, [url], null, "AUTH")
const [event] = await system.user.publish(rawEvent, [url], null, "AUTH")
return event
}

View File

@ -2,12 +2,13 @@
import cx from "classnames"
import {theme, installPrompt} from "src/partials/state"
import PersonCircle from "src/app/shared/PersonCircle.svelte"
import {FORCE_RELAYS, user, directory, alerts} from "src/app/system"
import {FORCE_RELAYS, user, directory, alerts, chat} from "src/app/system"
import {watch} from "src/util/loki"
import {routes, slowConnections, menuIsOpen} from "src/app/state"
const {canSign} = user.keys
const {hasNewNotfications, hasNewChatMessages, hasNewDirectMessages} = alerts
const {hasNewNotfications} = alerts
const {hasNewChatMessages, hasNewDirectMessages} = chat
const profile = watch(directory.profiles, () =>
user.getPubkey() ? directory.getProfile(user.getPubkey()) : null
)

View File

@ -2,10 +2,11 @@
import {onMount} from "svelte"
import {appName} from "src/partials/state"
import Anchor from "src/partials/Anchor.svelte"
import {alerts} from "src/app/system"
import {alerts, chat} from "src/app/system"
import {menuIsOpen} from "src/app/state"
const {hasNewNotfications, hasNewChatMessages, hasNewDirectMessages} = alerts
const {hasNewNotfications} = alerts
const {hasNewChatMessages, hasNewDirectMessages} = chat
const logoUrl = import.meta.env.VITE_LOGO_URL || "/images/logo.png"
const toggleMenu = () => menuIsOpen.update(x => !x)

View File

@ -14,7 +14,7 @@
import CopyValue from "src/partials/CopyValue.svelte"
import PersonBadge from "src/app/shared/PersonBadge.svelte"
import RelayCard from "src/app/shared/RelayCard.svelte"
import {ENABLE_ZAPS, FORCE_RELAYS, nip57, builder, routing, outbox, user} from "src/app/system"
import {ENABLE_ZAPS, FORCE_RELAYS, nip57, builder, routing, user} from "src/app/system"
export let note
export let reply
@ -39,13 +39,16 @@
const mute = () => user.mute("p", note.pubkey)
const react = async content => {
const relays = routing.getPublishHints(3, note)
const relays = routing.getPublishHints(3, note, user.getRelayUrls("write"))
like = first(await outbox.publish(builder.createReaction(note, content), relays))
like = first(await user.publish(builder.createReaction(note, content), relays))
}
const deleteReaction = e => {
outbox.publish(builder.deleteEvents([e.id]), routing.getPublishHints(3, note))
user.publish(
builder.deleteEvents([e.id]),
routing.getPublishHints(3, note, user.getRelayUrls("write"))
)
like = null
likes = reject(propEq("id", e.id), likes)

View File

@ -53,7 +53,7 @@
if (content) {
const rawEvent = builder.createReply(note, content, data.mentions.map(builder.mention))
const relays = routing.getPublishHints(3, note)
const relays = routing.getPublishHints(3, note, user.getRelayUrls("write"))
const [event, promise] = await publishWithToast(rawEvent, relays)
promise.then(({succeeded}) => {

View File

@ -5,8 +5,7 @@
import Content from "src/partials/Content.svelte"
import Spinner from "src/partials/Spinner.svelte"
import PersonInfo from "src/app/shared/PersonInfo.svelte"
import {social, routing, user, network} from "src/app/system"
import {pubkeyLoader} from "src/app/state"
import {social, routing, user, network, pubkeyLoader} from "src/app/system"
export let type
export let pubkey

View File

@ -11,17 +11,16 @@ import {hash, timedelta, now, batch, shuffle, sleep, clamp} from "src/util/misc"
import {userKinds, noteKinds} from "src/util/nostr"
import {findReplyId} from "src/util/nostr"
import {modal, toast} from "src/partials/state"
import {PubkeyLoader} from "src/system"
import system, {
import {
FORCE_RELAYS,
DEFAULT_FOLLOWS,
ENABLE_ZAPS,
pubkeyLoader,
alerts,
cache,
chat,
meta,
network,
outbox,
user,
} from "src/app/system"
@ -86,8 +85,6 @@ export const logUsage = async name => {
}
}
export const pubkeyLoader = new PubkeyLoader(system)
// Synchronization from events to state
export const listen = async () => {
@ -207,7 +204,7 @@ export const mergeParents = (notes: Array<DisplayEvent>) => {
}
export const publishWithToast = (event, relays) =>
outbox.publish(event, relays, ({completed, succeeded, failed, timeouts, pending}) => {
user.publish(event, relays, ({completed, succeeded, failed, timeouts, pending}) => {
let message = `Published to ${succeeded.size}/${relays.length} relays`
const extra = []

View File

@ -1,21 +1,55 @@
export * from "src/system/env"
import {System} from "src/system"
import {identity} from "ramda"
import {DefaultSystem} from "src/system"
const system = new System("coracle/system")
export const DUFFLEPUD_URL = import.meta.env.VITE_DUFFLEPUD_URL
export const MULTIPLEXTR_URL = import.meta.env.VITE_MULTIPLEXTR_URL
export const FORCE_RELAYS = (import.meta.env.VITE_FORCE_RELAYS || "").split(",").filter(identity)
export const COUNT_RELAYS = FORCE_RELAYS.length > 0 ? FORCE_RELAYS : ["wss://rbr.bio"]
export const SEARCH_RELAYS = FORCE_RELAYS.length > 0 ? FORCE_RELAYS : ["wss://relay.nostr.band"]
export const DEFAULT_RELAYS =
FORCE_RELAYS.length > 0
? FORCE_RELAYS
: [
"wss://purplepag.es",
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://relayable.org",
"wss://nostr.wine",
]
export const DEFAULT_FOLLOWS = (import.meta.env.VITE_DEFAULT_FOLLOWS || "")
.split(",")
.filter(identity)
export const ENABLE_ZAPS = JSON.parse(import.meta.env.VITE_ENABLE_ZAPS)
const system = new DefaultSystem("coracle/system", {
DUFFLEPUD_URL,
MULTIPLEXTR_URL,
FORCE_RELAYS,
COUNT_RELAYS,
SEARCH_RELAYS,
DEFAULT_RELAYS,
})
export default system
export const user = system.user
export const sync = system.sync
export const social = system.social
export const network = system.network
export const meta = system.meta
export const user = system.user
export const cache = system.cache
export const content = system.content
export const directory = system.directory
export const nip05 = system.nip05
export const nip57 = system.nip57
export const social = system.social
export const routing = system.routing
export const cache = system.cache
export const chat = system.chat
export const alerts = system.alerts
export const content = system.content
export const network = system.network
export const chat = system.chat
export const builder = system.builder
export const outbox = system.outbox
export const meta = system.meta
export const pubkeyLoader = system.pubkeyLoader

View File

@ -7,16 +7,15 @@
import Anchor from "src/partials/Anchor.svelte"
import PersonBadge from "src/app/shared/PersonBadge.svelte"
import NoteContent from "src/app/shared/NoteContent.svelte"
import {builder, chat, user, routing, network, outbox} from "src/app/system"
import {watch} from "src/util/loki"
import {builder, chat, user, routing, network} from "src/app/system"
export let entity
const id = toHex(entity)
const channel = watch(chat.channels, () => chat.channels.get(id) || {id})
const channel = chat.channels.watch(() => chat.channels.get(id) || {id})
const getRelays = () => routing.selectHints(user.getSetting("relay_limit"), $channel.hints || [])
chat.setLastChecked(id, now())
user.setLastChecked(id, now())
const edit = () => {
modal.push({type: "channel/edit", channel: $channel})
@ -25,7 +24,7 @@
const sendMessage = async content => {
const [hint] = getRelays()
await outbox.publish(builder.createChatMessage(id, content, hint), getRelays())
await user.publish(builder.createChatMessage(id, content, hint), getRelays())
}
onMount(() => {

View File

@ -8,7 +8,7 @@
import Textarea from "src/partials/Textarea.svelte"
import Anchor from "src/partials/Anchor.svelte"
import {toast, modal} from "src/partials/state"
import {builder, chat, user} from "src/app/system"
import {builder, user} from "src/app/system"
import {publishWithToast} from "src/app/state"
export let channel = {name: null, id: null, about: null, picture: null}
@ -43,7 +43,7 @@
const [event] = await publishWithToast(builder.createChannel(channel), relays)
// Auto join the room the user just created
await chat.joinChannel(event.id)
await user.joinChannel(event.id)
}
modal.pop()

View File

@ -12,9 +12,9 @@
import Anchor from "src/partials/Anchor.svelte"
import Modal from "src/partials/Modal.svelte"
import RelayCard from "src/app/shared/RelayCard.svelte"
import {DEFAULT_RELAYS, FORCE_RELAYS, routing, user, network} from "src/app/system"
import {DEFAULT_RELAYS, FORCE_RELAYS, routing, user, pubkeyLoader, network} from "src/app/system"
import {watch} from "src/util/loki"
import {loadAppData, pubkeyLoader} from "src/app/state"
import {loadAppData} from "src/app/state"
let modal = null
let customRelayUrl = null

View File

@ -7,7 +7,7 @@
import Anchor from "src/partials/Anchor.svelte"
import NoteContent from "src/app/shared/NoteContent.svelte"
import {watch} from "src/util/loki"
import {user, routing, directory, builder, chat, network, outbox} from "src/app/system"
import {user, routing, directory, builder, network} from "src/app/system"
import {routes} from "src/app/state"
import PersonCircle from "src/app/shared/PersonCircle.svelte"
import PersonAbout from "src/app/shared/PersonAbout.svelte"
@ -18,7 +18,7 @@
const pubkey = toHex(entity)
const profile = watch(directory.profiles, () => directory.getProfile(pubkey))
chat.setLastChecked(pubkey, now())
user.setLastChecked(pubkey, now())
const getRelays = () =>
routing.mergeHints(3, [
@ -28,10 +28,7 @@
const sendMessage = async content => {
const cyphertext = await user.crypt.encrypt(pubkey, content)
const [event] = await outbox.publish(
builder.createDirectMessage(pubkey, cyphertext),
getRelays()
)
const [event] = await user.publish(builder.createDirectMessage(pubkey, cyphertext), getRelays())
// Return unencrypted content so we can display it immediately
return {...event, content}

View File

@ -8,7 +8,7 @@
import Anchor from "src/partials/Anchor.svelte"
import Input from "src/partials/Input.svelte"
import Textarea from "src/partials/Textarea.svelte"
import {directory, routing, user, network, outbox, builder, nip57} from "src/app/system"
import {directory, routing, user, network, builder, nip57} from "src/app/system"
export let note
@ -29,8 +29,8 @@
const amount = zap.amount * 1000
const zapper = nip57.zappers.get(note.pubkey)
const relays = routing.getPublishHints(3, note)
const event = await outbox.prep(
const relays = routing.getPublishHints(3, note, user.getRelayUrls("write"))
const event = await user.prepEvent(
builder.requestZap(relays, zap.message, note.pubkey, note.id, amount, zapper.lnurl)
)
const eventString = encodeURI(JSON.stringify(event))

View File

@ -9,8 +9,8 @@
import OnboardingRelays from "src/app/views/OnboardingRelays.svelte"
import OnboardingFollows from "src/app/views/OnboardingFollows.svelte"
import OnboardingNote from "src/app/views/OnboardingNote.svelte"
import {DEFAULT_FOLLOWS, DEFAULT_RELAYS, builder, user} from "src/app/system"
import {loadAppData, pubkeyLoader} from "src/app/state"
import {DEFAULT_FOLLOWS, DEFAULT_RELAYS, pubkeyLoader, builder, user} from "src/app/system"
import {loadAppData} from "src/app/state"
import {modal} from "src/partials/state"
export let stage

View File

@ -1,82 +0,0 @@
import {sortBy} from "ramda"
import type {Writable, Readable} from "svelte/store"
import {synced} from "src/util/misc"
import {Tags, isLike, findReplyId, findRootId} from "src/util/nostr"
import {derived} from "svelte/store"
import {Table, watch} from "src/util/loki"
import type {System} from "src/system/system"
export class Alerts {
system: System
events: Table<Event>
lastChecked: Writable<number>
latestNotification: Writable<number>
hasNewNotfications: Readable<boolean>
hasNewDirectMessages: Readable<boolean>
hasNewChatMessages: Readable<boolean>
constructor(system) {
this.system = system
this.events = new Table(system.key("alerts/events"), "id", {sort: sortBy(e => -e.created_at)})
this.lastChecked = synced(system.key("alerts/lastChecked"), 0)
this.latestNotification = synced(system.key("alerts/latestNotification"), 0)
const isMention = e =>
Tags.from(e).type("p").values().all().includes(system.user.getPubkey())
const isReply = e => system.user.isUserEvent(findReplyId(e))
const isDescendant = e => system.user.isUserEvent(findRootId(e))
const handleNotification = condition => e => {
if (!system.user.getPubkey()) {
return
}
if (e.pubkey === system.user.getPubkey()) {
return
}
if (!condition(e)) {
return
}
if (system.user.isMuted(e)) {
return
}
if (!this.events.get(e.id)) {
this.events.patch(e)
}
}
system.sync.addHandler(
1,
handleNotification(e => isMention(e) || isReply(e) || isDescendant(e))
)
system.sync.addHandler(
7,
handleNotification(e => isLike(e.content) && isReply(e))
)
system.sync.addHandler(9735, handleNotification(isReply))
this.hasNewNotfications = derived(
[this.lastChecked, this.latestNotification],
([$lastChecked, $latestNotification]) => $latestNotification > $lastChecked
)
this.hasNewDirectMessages = watch(system.chat.channels, () => {
const channels = system.chat.channels.all({type: "private", last_sent: {$type: "number"}})
return channels.filter(this.messageIsNew).length > 0
})
this.hasNewChatMessages = watch(system.chat.channels, () => {
const channels = system.chat.channels.all({type: "public", joined: true})
return channels.filter(this.messageIsNew).length > 0
})
}
messageIsNew = ({last_checked, last_received, last_sent}) =>
last_received > Math.max(last_sent || 0, last_checked || 0)
}

View File

@ -1,37 +0,0 @@
import type {Event as NostrToolsEvent} from "nostr-tools"
import {sortBy} from "ramda"
import {Table} from "src/util/loki"
import type {System} from "src/system/system"
export type Event = NostrToolsEvent & {
seen_on: string[]
}
export class Cache {
system: System
events: Table<Event>
constructor(system) {
this.system = system
this.events = new Table(system.key("cache/events"), "id", {
max: 5000,
sort: events => {
const pubkey = system.user.getPubkey()
const follows = system.user.getFollowsSet()
return sortBy(e => {
if (e.pubkey === pubkey) return 0
if (follows.has(e.pubkey)) return 1
return Number.MAX_SAFE_INTEGER - e.created_at
}, events)
},
})
system.sync.addHandler(system.sync.ANY_KIND, e => {
if (e.pubkey === system.user.getPubkey() && !this.events.get(e.id)) {
this.events.patch(e)
}
})
}
}

View File

@ -1,34 +1,37 @@
import {Socket} from "paravel"
import {now} from "src/util/misc"
import {switcher} from "hurdak/lib/hurdak"
import type {System} from "src/system/system"
import type {RelayStat} from "src/system/types"
import type {Network} from "src/system/components/Network"
export type MetaOpts = {
network: Network
}
export class Meta {
system: System
network: Network
relayStats: Record<string, RelayStat>
constructor(system) {
this.system = system
constructor({network}: MetaOpts) {
this.network = network
this.relayStats = {}
system.network.pool.on("open", ({url}) => {
network.pool.on("open", ({url}) => {
this.updateRelayStats(url, {last_opened: now(), last_activity: now()})
})
system.network.pool.on("close", ({url}) => {
network.pool.on("close", ({url}) => {
this.updateRelayStats(url, {last_closed: now(), last_activity: now()})
})
system.network.pool.on("error:set", (url, error) => {
network.pool.on("error:set", (url, error) => {
this.updateRelayStats(url, {error})
})
system.network.pool.on("error:clear", url => {
network.pool.on("error:clear", url => {
this.updateRelayStats(url, {error: null})
})
system.network.on("publish", urls => {
network.on("publish", urls => {
for (const url of urls) {
this.updateRelayStats(url, {
last_publish: now(),
@ -37,7 +40,7 @@ export class Meta {
}
})
system.network.on("sub:open", urls => {
network.on("sub:open", urls => {
for (const url of urls) {
const stats = this.getRelayStats(url)
@ -50,7 +53,7 @@ export class Meta {
}
})
system.network.on("sub:close", urls => {
network.on("sub:close", urls => {
for (const url of urls) {
const stats = this.getRelayStats(url)
@ -61,7 +64,7 @@ export class Meta {
}
})
system.network.on("event", ({url}) => {
network.on("event", ({url}) => {
const stats = this.getRelayStats(url)
this.updateRelayStats(url, {
@ -70,7 +73,7 @@ export class Meta {
})
})
system.network.on("eose", (url, ms) => {
network.on("eose", (url, ms) => {
const stats = this.getRelayStats(url)
this.updateRelayStats(url, {
@ -80,7 +83,7 @@ export class Meta {
})
})
system.network.on("timeout", (url, ms) => {
network.on("timeout", (url, ms) => {
const stats = this.getRelayStats(url)
this.updateRelayStats(url, {
@ -132,7 +135,7 @@ export class Meta {
return [eoseQuality, "Connected"]
}
if (this.system.network.pool.get(url).status === Socket.STATUS.READY) {
if (this.network.pool.get(url).status === Socket.STATUS.READY) {
return [1, "Connected"]
}

View File

@ -1,5 +1,3 @@
import type {Filter} from "nostr-tools"
import type {MyEvent} from "src/util/types"
import {EventEmitter} from "events"
import {verifySignature, matchFilters} from "nostr-tools"
import {Pool, Plex, Relays, Executor, Socket} from "paravel"
@ -7,13 +5,20 @@ import {ensurePlural} from "hurdak/lib/hurdak"
import {union, difference} from "src/util/misc"
import {warn, error, log} from "src/util/logger"
import {normalizeRelayUrl} from "src/util/nostr"
import {FORCE_RELAYS, COUNT_RELAYS} from "src/system/env"
import type {System} from "src/system/system"
import type {Event, Filter, RelayInfo} from "src/system/types"
type SubscribeOpts = {
export type NetworkOpts = {
getMultiplextrUrl: () => string | null
processEvents: (events: Event[]) => void
getRelayInfo: (url: string) => RelayInfo | null
forceRelays: string[]
countRelays: string[]
}
export type SubscribeOpts = {
relays: string[]
filter: Filter[] | Filter
onEvent?: (event: MyEvent) => void
onEvent?: (event: Event) => void
onEose?: (url: string) => void
shouldProcess?: boolean
}
@ -34,23 +39,21 @@ const getUrls = relays => {
export class Network extends EventEmitter {
authHandler?: (url: string, challenge: string) => void
system: System
pool: Pool
constructor(system) {
constructor(readonly opts: NetworkOpts) {
super()
this.authHandler = null
this.system = system
this.pool = new Pool()
}
getExecutor = (urls, {bypassBoot = false} = {}) => {
if (FORCE_RELAYS.length > 0) {
urls = FORCE_RELAYS
if (this.opts.forceRelays.length > 0) {
urls = this.opts.forceRelays
}
let target
const muxUrl = this.system.user.getSetting("multiplextr_url")
const muxUrl = this.opts.getMultiplextrUrl()
// 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
@ -89,7 +92,7 @@ export class Network extends EventEmitter {
// Eagerly connect and handle AUTH
executor.target.sockets.forEach(socket => {
const {limitation} = this.system.routing.getRelayInfo(socket.url)
const {limitation} = this.opts.getRelayInfo(socket.url)
const waitForBoot = limitation?.payment_required || limitation?.auth_required
// This happens automatically, but kick it off anyway
@ -219,7 +222,7 @@ export class Network extends EventEmitter {
this.emit("event", {url, event})
if (shouldProcess) {
this.system.sync.processEvents([event])
this.opts.processEvents([event])
}
onEvent(event)
@ -262,7 +265,7 @@ export class Network extends EventEmitter {
}: {
relays: string[]
filter: Filter | Filter[]
onEvent?: (event: MyEvent) => void
onEvent?: (event: Event) => void
shouldProcess?: boolean
timeout?: number
}) => {
@ -310,11 +313,11 @@ export class Network extends EventEmitter {
attemptToComplete(false)
},
})
}) as Promise<MyEvent[]>
}) as Promise<Event[]>
}
count = async filter => {
const filters = ensurePlural(filter)
const executor = this.getExecutor(COUNT_RELAYS)
const executor = this.getExecutor(this.opts.countRelays)
return new Promise(resolve => {
const sub = executor.count(filters, {

View File

@ -0,0 +1,70 @@
import {identity, sortBy, prop} from "ramda"
import {ensurePlural, chunk} from "hurdak/lib/hurdak"
import {Table} from "src/util/loki"
import {sleep, synced} from "src/util/misc"
import type {SystemEnv} from "src/system/System"
type SyncOpts = {
getUserPubkey: () => null | string
getPubkeyWhitelist: () => null | Set<string>
}
export class Sync {
ns: string
env: SystemEnv
stores = {}
handlers = {}
ANY_KIND: string
getUserPubkey: SyncOpts["getUserPubkey"]
getPubkeyWhitelist: SyncOpts["getPubkeyWhitelist"]
constructor(system, {getUserPubkey, getPubkeyWhitelist}: SyncOpts) {
this.ns = system.ns
this.env = system.env
this.ANY_KIND = this.key(this.key("ANY_KIND"))
this.getUserPubkey = getUserPubkey
this.getPubkeyWhitelist = getPubkeyWhitelist
}
key = key => `${this.ns}/${key}`
table = <T>(name, pk, opts = {}) => new Table<T>(this.key(name), pk, opts)
store = (name, defaultValue) => synced(this.key(name), defaultValue)
addHandler(kind, f) {
this.handlers[kind] = this.handlers[kind] || []
this.handlers[kind].push(f)
}
sortByPubkeyWhitelist = xs => {
const whitelist = this.getPubkeyWhitelist()
const sort = sortBy(
whitelist ? x => (whitelist.has(x.pubkey) ? 0 : x.updated_at) : prop("updated_at")
)
return sort(xs)
}
async processEvents(events) {
const chunks = chunk(100, ensurePlural(events).filter(identity))
for (let i = 0; i < chunks.length; i++) {
for (const event of chunks[i]) {
for (const handler of this.handlers[this.ANY_KIND] || []) {
await handler(event)
}
for (const handler of this.handlers[event.kind] || []) {
await handler(event)
}
}
// Don't lock up the ui when processing a lot of events
if (i < chunks.length - 1) {
await sleep(30)
}
}
}
}

View File

@ -1,12 +1,30 @@
import {nip19, nip04, getPublicKey, getSignature, generatePrivateKey} from "nostr-tools"
import {
nip19,
nip04,
getPublicKey,
getSignature,
getEventHash,
generatePrivateKey,
} from "nostr-tools"
import NDK, {NDKEvent, NDKNip46Signer, NDKPrivateKeySigner} from "@nostr-dev-kit/ndk"
import {when, whereEq, find, slice, reject, prop} from "ramda"
import {switcherFn} from "hurdak/lib/hurdak"
import {
when,
uniq,
pluck,
without,
fromPairs,
whereEq,
find,
slice,
assoc,
reject,
prop,
} from "ramda"
import {switcherFn, doPipe} from "hurdak/lib/hurdak"
import {derived} from "svelte/store"
import {synced, now, tryJson, tryFunc, sleep, getter} from "src/util/misc"
import {now, tryJson, tryFunc, sleep, getter} from "src/util/misc"
import {Tags, normalizeRelayUrl, findReplyId, findRootId} from "src/util/nostr"
import {DUFFLEPUD_URL, MULTIPLEXTR_URL} from "src/system/env"
import type {System} from "src/system/system"
import type {System} from "src/system/System"
import type {UserSettings, KeyState} from "src/system/types"
import type {Writable, Readable} from "svelte/store"
@ -25,13 +43,15 @@ export class Crypt {
constructor(keys) {
this.keys = keys
}
async encrypt(pubkey, message) {
const {method, privkey} = this.keys.getState()
return switcherFn(method, {
extension: extension => withExtensionLock(() => {
return getExtension().nip04.encrypt(pubkey, message)
}),
extension: extension =>
withExtensionLock(() => {
return getExtension().nip04.encrypt(pubkey, message)
}),
privkey: extension => nip04.encrypt(privkey, pubkey, message),
bunker: async () => {
const ndk = await this.keys.getNDK()
@ -41,28 +61,30 @@ export class Crypt {
},
})
}
async decrypt(pubkey, message) {
const {method, privkey} = this.keys.getState()
return switcherFn(method, {
extension: () => withExtensionLock(() => {
return new Promise(async resolve => {
let result
extension: () =>
withExtensionLock(() => {
return new Promise(async resolve => {
let result
// Alby gives us a bunch of bogus errors, try multiple times
for (let i = 0; i < 3; i++) {
result = await tryFunc(() => getExtension().nip04.decrypt(pubkey, message))
// Alby gives us a bunch of bogus errors, try multiple times
for (let i = 0; i < 3; i++) {
result = await tryFunc(() => getExtension().nip04.decrypt(pubkey, message))
if (result) {
break
if (result) {
break
}
await sleep(30)
}
await sleep(30)
}
resolve(result || `<Failed to decrypt message>`)
})
}),
resolve(result || `<Failed to decrypt message>`)
})
}),
privkey: () => {
return (
tryFunc(() => nip04.decrypt(privkey, pubkey, message)) || `<Failed to decrypt message>`
@ -76,11 +98,13 @@ export class Crypt {
},
})
}
async encryptJson(data) {
const {pubkey} = this.keys.getState()
return this.encrypt(pubkey, JSON.stringify(data))
}
async decryptJson(data) {
const {pubkey} = this.keys.getState()
@ -94,8 +118,9 @@ export class Keys {
pubkey: Readable<string>
canSign: Readable<boolean>
ndk: NDK
constructor(user) {
this.keyState = synced(user.system.key("keys"), {})
constructor(sync) {
this.keyState = sync.store("keys", {})
this.getState = getter(this.keyState)
this.pubkey = derived(this.keyState, prop("pubkey"))
this.canSign = derived(this.keyState, ({method}) =>
@ -103,6 +128,9 @@ export class Keys {
)
this.ndk = null
}
getPubkey = () => this.getState().pubkey
isKeyValid = key => {
// Validate the key before setting it to state by encoding it using bech32.
// This will error if invalid (this works whether it's a public or a private key)
@ -114,6 +142,7 @@ export class Keys {
return true
}
prepareNdk = async (token?: string) => {
const {pubkey, bunkerKey} = this.getState()
const localSigner = new NDKPrivateKeySigner(bunkerKey)
@ -131,6 +160,7 @@ export class Keys {
await this.ndk.connect(5000)
await this.ndk.signer.blockUntilReady()
}
getNDK = async () => {
if (!this.ndk) {
await this.prepareNdk()
@ -138,6 +168,7 @@ export class Keys {
return this.ndk
}
login = (method, key) => {
this.keyState.update($state => {
let pubkey = null
@ -159,9 +190,11 @@ export class Keys {
return {method, pubkey, privkey, bunkerKey}
})
}
clear = () => {
this.keyState.set({})
}
sign = async event => {
const {method, privkey} = this.getState()
@ -182,9 +215,10 @@ export class Keys {
sig: getSignature(event, privkey),
})
},
extension: () => withExtensionLock(() => {
return getExtension().signEvent(event)
}),
extension: () =>
withExtensionLock(() => {
return getExtension().signEvent(event)
}),
})
}
}
@ -196,20 +230,21 @@ export class User {
canSign: () => boolean
settings: Writable<UserSettings>
getSettings: () => UserSettings
constructor(system) {
this.system = system
this.keys = new Keys(this)
this.keys = new Keys(system.sync)
this.crypt = new Crypt(this.keys)
this.canSign = getter(this.keys.canSign)
this.settings = synced(system.key("settings/settings"), {
this.settings = system.sync.store("settings/settings", {
last_updated: 0,
relay_limit: 10,
default_zap: 21,
show_media: true,
report_analytics: true,
dufflepud_url: DUFFLEPUD_URL,
multiplextr_url: MULTIPLEXTR_URL,
dufflepud_url: system.env.DUFFLEPUD_URL,
multiplextr_url: system.env.MULTIPLEXTR_URL,
})
this.getSettings = getter(this.settings)
@ -236,7 +271,34 @@ export class User {
getStateKey = () => (this.canSign() ? this.getPubkey() : "anonymous")
publish = event => this.system.outbox.publish(event, this.getRelayUrls("write"))
// Publish
async prepEvent(rawEvent) {
return doPipe(rawEvent, [
assoc("created_at", now()),
assoc("pubkey", this.getPubkey()),
e => ({...e, id: getEventHash(e)}),
this.keys.sign,
])
}
async publish(event, relays = null, onProgress = null, verb = "EVENT") {
if (!event.sig) {
event = await this.prepEvent(event)
}
if (!relays) {
relays = this.getRelayUrls("write")
}
// return console.log(event)
const promise = this.system.network.publish({event, relays, onProgress, verb})
this.system.sync.processEvents(event)
return [event, promise]
}
// Settings
@ -366,4 +428,33 @@ export class User {
this.publish(this.system.builder.createList([["d", name]].concat(params).concat(relays)))
removeList = naddr => this.publish(this.system.builder.deleteNaddrs([naddr]))
// Chat
setLastChecked = (channelId, timestamp) => {
const lastChecked = fromPairs(
this.system.chat.channels
.all({last_checked: {$type: "number"}})
.map(r => [r.id, r.last_checked])
)
return this.setAppData("last_checked/v1", {...lastChecked, [channelId]: timestamp})
}
joinChannel = channelId => {
const channelIds = uniq(
pluck("id", this.system.chat.channels.all({joined: true})).concat(channelId)
)
return this.setAppData("rooms_joined/v1", channelIds)
}
leaveChannel = channelId => {
const channelIds = without(
[channelId],
pluck("id", this.system.chat.channels.all({joined: true}))
)
return this.setAppData("rooms_joined/v1", channelIds)
}
}

View File

@ -1,28 +0,0 @@
import {identity} from "ramda"
export const DUFFLEPUD_URL = import.meta.env.VITE_DUFFLEPUD_URL
export const MULTIPLEXTR_URL = import.meta.env.VITE_MULTIPLEXTR_URL
export const FORCE_RELAYS = (import.meta.env.VITE_FORCE_RELAYS || "").split(",").filter(identity)
export const COUNT_RELAYS = FORCE_RELAYS.length > 0 ? FORCE_RELAYS : ["wss://rbr.bio"]
export const SEARCH_RELAYS = FORCE_RELAYS.length > 0 ? FORCE_RELAYS : ["wss://relay.nostr.band"]
export const DEFAULT_RELAYS =
FORCE_RELAYS.length > 0
? FORCE_RELAYS
: [
"wss://purplepag.es",
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://relayable.org",
"wss://nostr.wine",
]
export const DEFAULT_FOLLOWS = (import.meta.env.VITE_DEFAULT_FOLLOWS || "")
.split(",")
.filter(identity)
export const ENABLE_ZAPS = JSON.parse(import.meta.env.VITE_ENABLE_ZAPS)

View File

@ -1,17 +1,17 @@
export {Sync} from "src/system/sync"
export {Social} from "src/system/social"
export {Directory} from "src/system/directory"
export {Nip05} from "src/system/nip05"
export {Nip57} from "src/system/nip57"
export {Content} from "src/system/content"
export {Routing} from "src/system/routing"
export {Cache} from "src/system/cache"
export {Chat} from "src/system/chat"
export {Alerts} from "src/system/alerts"
export {Network} from "src/system/network"
export {Outbox} from "src/system/outbox"
export {Builder} from "src/system/builder"
export {Meta} from "src/system/meta"
export {Crypt, Keys, User} from "src/system/user"
export {System} from "src/system/system"
export type * from "src/system/types"
export {Directory} from "src/system/stores/Directory"
export {Routing} from "src/system/stores/Routing"
export {Content} from "src/system/stores/Content"
export {Nip57} from "src/system/stores/Nip57"
export {Chat} from "src/system/stores/Chat"
export {Nip05} from "src/system/stores/Nip05"
export {Cache} from "src/system/stores/Cache"
export {Social} from "src/system/stores/Social"
export {Alerts} from "src/system/stores/Alerts"
export {Meta} from "src/system/components/Meta"
export {Network} from "src/system/components/Network"
export {User} from "src/system/components/User"
export {Sync} from "src/system/components/Sync"
export {PubkeyLoader} from "src/system/util/PubkeyLoader"
export {Builder} from "src/system/util/Builder"
export {DefaultSystem} from "src/system/System"

View File

@ -1,35 +0,0 @@
import {assoc} from "ramda"
import {getEventHash} from "nostr-tools"
import {doPipe} from "hurdak/lib/hurdak"
import {now} from "src/util/misc"
import type {System} from "src/system/system"
export class Outbox {
system: System
constructor(system) {
this.system = system
}
async prep(rawEvent) {
return doPipe(rawEvent, [
assoc("created_at", now()),
assoc("pubkey", this.system.user.getPubkey()),
e => ({...e, id: getEventHash(e)}),
this.system.user.keys.sign,
])
}
async publish(event, relays, onProgress = null, verb = "EVENT") {
if (!event.sig) {
event = await this.prep(event)
}
// return console.log(event)
const promise = this.system.network.publish({relays, event, onProgress, verb})
this.system.sync.processEvents(event)
return [event, promise]
}
}

View File

@ -0,0 +1,64 @@
import {sortBy} from "ramda"
import type {Writable, Readable} from "svelte/store"
import {Tags, isLike, findReplyId, findRootId} from "src/util/nostr"
import {derived} from "svelte/store"
import type {Table} from "src/util/loki"
import type {Sync} from "src/system/components/Sync"
import type {Event} from "src/system/types"
export type AlertsOpts = {
getUserPubkey: () => null | string
isUserEvent: (e: Event) => boolean
isMuted: (e: Event) => boolean
}
export class Alerts {
events: Table<Event>
lastChecked: Writable<number>
latestNotification: Writable<number>
hasNewNotfications: Readable<boolean>
constructor(sync: Sync, readonly opts: AlertsOpts) {
this.events = sync.table("alerts/events", "id", {sort: sortBy(e => -e.created_at)})
this.lastChecked = sync.store("alerts/lastChecked", 0)
this.latestNotification = sync.store("alerts/latestNotification", 0)
this.hasNewNotfications = derived(
[this.lastChecked, this.latestNotification],
([$lastChecked, $latestNotification]) => $latestNotification > $lastChecked
)
const isMention = e => Tags.from(e).pubkeys().includes(this.opts.getUserPubkey())
const isDescendant = e => this.opts.isUserEvent(findRootId(e))
const isReply = e => this.opts.isUserEvent(findReplyId(e))
const handleNotification = condition => e => {
if (!this.opts.getUserPubkey() || e.pubkey === this.opts.getUserPubkey()) {
return
}
if (!condition(e)) {
return
}
if (this.opts.isMuted(e)) {
return
}
if (!this.events.get(e.id)) {
this.events.patch(e)
}
}
sync.addHandler(
1,
handleNotification(e => isMention(e) || isReply(e) || isDescendant(e))
)
sync.addHandler(
7,
handleNotification(e => isLike(e.content) && isReply(e))
)
sync.addHandler(9735, handleNotification(isReply))
}
}

View File

@ -0,0 +1,28 @@
import type {Event as NostrToolsEvent} from "nostr-tools"
import {sortBy} from "ramda"
import type {Table} from "src/util/loki"
export type Event = NostrToolsEvent & {
seen_on: string[]
}
export class Cache {
events: Table<Event>
constructor(sync) {
this.events = sync.table("cache/events", "id", {
max: 5000,
sort: events => {
const sortByPubkeyWhitelist = e =>
sync.getUserPubkey() === e.pubkey ? 0 : Number.MAX_SAFE_INTEGER - e.created_at
return sortBy(sortByPubkeyWhitelist, events)
},
})
sync.addHandler(sync.ANY_KIND, e => {
if (e.pubkey === sync.getUserPubkey() && !this.events.get(e.id)) {
this.events.patch(e)
}
})
}
}

View File

@ -1,40 +1,29 @@
import {last, sortBy, pick, uniq, fromPairs, pluck, without} from "ramda"
import {last, sortBy, pick, uniq, pluck} from "ramda"
import {get} from "svelte/store"
import {tryJson, now, tryFunc} from "src/util/misc"
import {Tags, channelAttrs} from "src/util/nostr"
import {Table} from "src/util/loki"
import type {System} from "src/system/system"
import type {Table} from "src/util/loki"
import type {Readable} from "svelte/store"
import type {Sync} from "src/system/components/Sync"
import type {Crypt} from "src/system/components/User"
import type {Channel, Message} from "src/system/types"
const getHints = e => pluck("url", Tags.from(e).relays())
export type Channel = {
id: string
type: "public" | "private"
pubkey: string
updated_at: number
last_sent?: number
last_received?: number
last_checked?: number
joined?: boolean
hints: string[]
}
const messageIsNew = ({last_checked, last_received, last_sent}: Channel) =>
last_received > Math.max(last_sent || 0, last_checked || 0)
export type Message = {
id: string
channel: string
pubkey: string
created_at: number
content: string
tags: string[][]
export type ChatOpts = {
getCrypt: () => Crypt
}
export class Chat {
system: System
channels: Table<Channel>
messages: Table<Message>
constructor(system) {
this.system = system
this.channels = new Table(system.key("chat/channels"), "id", {
hasNewDirectMessages: Readable<boolean>
hasNewChatMessages: Readable<boolean>
constructor(sync: Sync, readonly opts: ChatOpts) {
this.channels = sync.table("chat/channels", "id", {
sort: sortBy(x => {
if (x.joined || x.type === "private") return 0
if (!x.name || x.name.match(/test/i)) return Infinity
@ -43,7 +32,7 @@ export class Chat {
}),
})
this.messages = new Table(system.key("chat/messages"), "id", {
this.messages = sync.table("chat/messages", "id", {
sort: xs => {
const channelIds = new Set(
pluck("id", this.channels.all({$or: [{joined: true}, {type: "private"}]}))
@ -53,7 +42,19 @@ export class Chat {
},
})
system.sync.addHandler(40, e => {
this.hasNewDirectMessages = this.channels.watch(() => {
const channels = this.channels.all({type: "private", last_sent: {$type: "number"}})
return channels.filter(e => messageIsNew(e)).length > 0
})
this.hasNewChatMessages = this.channels.watch(() => {
const channels = this.channels.all({type: "public", joined: true})
return channels.filter(e => messageIsNew(e)).length > 0
})
sync.addHandler(40, e => {
const channel = this.channels.get(e.id)
if (e.created_at < channel?.updated_at) {
@ -76,7 +77,7 @@ export class Chat {
})
})
system.sync.addHandler(41, e => {
sync.addHandler(41, e => {
const channelId = Tags.from(e).getMeta("e")
if (!channelId) {
@ -109,10 +110,10 @@ export class Chat {
})
})
system.sync.addHandler(30078, async e => {
sync.addHandler(30078, async e => {
if (Tags.from(e).getMeta("d") === "coracle/last_checked/v1") {
await tryJson(async () => {
const payload = await this.system.user.crypt.decryptJson(e.content)
const payload = await this.opts.getCrypt().decryptJson(e.content)
for (const key of Object.keys(payload)) {
// Backwards compat from when we used to prefix id/pubkey
@ -131,10 +132,10 @@ export class Chat {
}
})
system.sync.addHandler(30078, async e => {
sync.addHandler(30078, async e => {
if (Tags.from(e).getMeta("d") === "coracle/rooms_joined/v1") {
await tryJson(async () => {
const channelIds = await this.system.user.crypt.decryptJson(e.content)
const channelIds = await this.opts.getCrypt().decryptJson(e.content)
// Just a bug from when I was building the feature, remove someday
if (!Array.isArray(channelIds)) {
@ -152,15 +153,15 @@ export class Chat {
}
})
system.sync.addHandler(4, async e => {
if (!this.system.user.canSign()) {
sync.addHandler(4, async e => {
if (!get(this.opts.getCrypt().keys.canSign)) {
return
}
const author = e.pubkey
const recipient = Tags.from(e).type("p").values().first()
if (![author, recipient].includes(this.system.user.getPubkey())) {
if (![author, recipient].includes(this.opts.getCrypt().keys.getPubkey())) {
return
}
@ -169,18 +170,18 @@ export class Chat {
}
await tryFunc(async () => {
const other = this.system.user.getPubkey() === author ? recipient : author
const other = this.opts.getCrypt().keys.getPubkey() === author ? recipient : author
this.messages.patch({
id: e.id,
channel: other,
pubkey: e.pubkey,
created_at: e.created_at,
content: await this.system.user.crypt.decrypt(other, e.content),
content: await this.opts.getCrypt().decrypt(other, e.content),
tags: e.tags,
})
if (this.system.user.getPubkey() === author) {
if (this.opts.getCrypt().keys.getPubkey() === author) {
const channel = this.channels.get(recipient)
this.channels.patch({
@ -202,7 +203,7 @@ export class Chat {
})
})
system.sync.addHandler(42, e => {
sync.addHandler(42, e => {
if (this.messages.get(e.id)) {
return
}
@ -229,24 +230,4 @@ export class Chat {
})
})
}
setLastChecked = (channelId, timestamp) => {
const lastChecked = fromPairs(
this.channels.all({last_checked: {$type: "number"}}).map(r => [r.id, r.last_checked])
)
return this.system.user.setAppData("last_checked/v1", {...lastChecked, [channelId]: timestamp})
}
joinChannel = channelId => {
const channelIds = uniq(pluck("id", this.channels.all({joined: true})).concat(channelId))
return this.system.user.setAppData("rooms_joined/v1", channelIds)
}
leaveChannel = channelId => {
const channelIds = without([channelId], pluck("id", this.channels.all({joined: true})))
return this.system.user.setAppData("rooms_joined/v1", channelIds)
}
}

View File

@ -3,8 +3,8 @@ import type {Readable} from "svelte/store"
import {sortBy, nth, inc} from "ramda"
import {fuzzy} from "src/util/misc"
import {Tags} from "src/util/nostr"
import {Table, watch} from "src/util/loki"
import type {System} from "src/system/system"
import type {Table} from "src/util/loki"
import type {Sync} from "src/system/components/Sync"
export type Topic = {
name: string
@ -22,18 +22,15 @@ export type List = {
}
export class Content {
system: System
topics: Table<Topic>
lists: Table<List>
searchTopics: Readable<(query: string) => Topic[]>
constructor(system) {
this.system = system
constructor(sync: Sync) {
this.topics = sync.table("content/topics", "name", {sort: sortBy(e => -e.count)})
this.topics = new Table(system.key("content/topics"), "name", {sort: sortBy(e => -e.count)})
this.lists = sync.table("content/lists", "naddr")
this.lists = new Table(system.key("content/lists"), "naddr")
this.searchTopics = watch(this.topics, () =>
this.searchTopics = this.topics.watch(() =>
fuzzy(this.topics.all(), {keys: ["name"], threshold: 0.3})
)
@ -50,10 +47,10 @@ export class Content {
}
}
system.sync.addHandler(1, processTopics)
system.sync.addHandler(42, processTopics)
sync.addHandler(1, processTopics)
sync.addHandler(42, processTopics)
system.sync.addHandler(30001, e => {
sync.addHandler(30001, e => {
const {pubkey, kind, created_at} = e
const name = Tags.from(e).getMeta("d")
const naddr = nip19.naddrEncode({identifier: name, pubkey, kind})
@ -75,7 +72,7 @@ export class Content {
})
})
system.sync.addHandler(5, e => {
sync.addHandler(5, e => {
Tags.from(e)
.type("a")
.values()

View File

@ -1,31 +1,28 @@
import {nip19} from "nostr-tools"
import {ellipsize} from "hurdak/lib/hurdak"
import {tryJson, now, fuzzy} from "src/util/misc"
import {Table, watch} from "src/util/loki"
import type {System} from "src/system/system"
import type {Table} from "src/util/loki"
import type {Readable} from "svelte/store"
import type {Sync} from "src/system/components/Sync"
import type {Profile} from "src/system/types"
export class Directory {
system: System
profiles: Table<Profile>
searchProfiles: Readable<(q: string) => Record<string, any>[]>
constructor(system) {
this.system = system
this.profiles = new Table(this.system.key("directory/profiles"), "pubkey", {
constructor(sync: Sync) {
this.profiles = sync.table("directory/profiles", "pubkey", {
max: 5000,
sort: this.system.sortByGraph,
sort: sync.sortByPubkeyWhitelist,
})
this.searchProfiles = watch(this.profiles, () => {
this.searchProfiles = this.profiles.watch(() => {
return fuzzy(this.getNamedProfiles(), {
keys: ["name", "display_name", {name: "nip05", weight: 0.5}, {name: "about", weight: 0.1}],
threshold: 0.3,
})
})
this.system.sync.addHandler(0, e => {
sync.addHandler(0, e => {
tryJson(() => {
const kind0 = JSON.parse(e.content)
const profile = this.profiles.get(e.pubkey)

View File

@ -1,29 +1,19 @@
import {last} from "ramda"
import {nip05} from "nostr-tools"
import {tryFunc, now, tryJson} from "src/util/misc"
import {Table} from "src/util/loki"
import type {System} from "src/system/system"
export type Handle = {
profile: Record<string, any>
pubkey: string
address: string
created_at: number
updated_at: number
}
import type {Table} from "src/util/loki"
import type {Sync} from "src/system/components/Sync"
import type {Handle} from "src/system/types"
export class Nip05 {
system: System
handles: Table<Handle>
constructor(system) {
this.system = system
this.handles = new Table(system.key("nip05/handles"), "pubkey", {
sort: system.sortByGraph,
constructor(sync: Sync) {
this.handles = sync.table("nip05/handles", "pubkey", {
max: 5000,
sort: sync.sortByPubkeyWhitelist,
})
system.sync.addHandler(0, e => {
sync.addHandler(0, e => {
tryJson(async () => {
const kind0 = JSON.parse(e.content)
const handle = this.handles.get(e.pubkey)

View File

@ -1,8 +1,9 @@
import {fetchJson, now, tryFunc, tryJson, hexToBech32, bech32ToHex} from "src/util/misc"
import {invoiceAmount} from "src/util/lightning"
import {Tags} from "src/util/nostr"
import {Table} from "src/util/loki"
import type {System} from "src/system/system"
import type {Table} from "src/util/loki"
import type {Sync} from "src/system/components/Sync"
import type {Zapper} from "src/system/types"
const getLnUrl = address => {
// Try to parse it as a lud06 LNURL
@ -20,29 +21,15 @@ const getLnUrl = address => {
}
}
export type Zapper = {
pubkey: string
lnurl: string
callback: string
minSendable: number
maxSendable: number
nostrPubkey: string
created_at: number
updated_at: number
}
export class Nip57 {
system: System
zappers: Table<Zapper>
constructor(system) {
this.system = system
this.zappers = new Table(system.key("niip57/zappers"), "pubkey", {
sort: system.sortByGraph,
constructor(sync: Sync) {
this.zappers = sync.table("niip57/zappers", "pubkey", {
max: 5000,
sort: sync.sortByPubkeyWhitelist,
})
system.sync.addHandler(0, e => {
sync.addHandler(0, e => {
tryJson(async () => {
const kind0 = JSON.parse(e.content)
const zapper = this.zappers.get(e.pubkey)

View File

@ -1,35 +1,35 @@
import {sortBy, pluck, uniq, nth, uniqBy, prop, last, inc} from "ramda"
import {first} from "hurdak/lib/hurdak"
import type {Readable} from "svelte/store"
import {fuzzy, chain, tryJson, now, fetchJson} from "src/util/misc"
import {warn} from "src/util/logger"
import {normalizeRelayUrl, findReplyId, isShareableRelay, Tags} from "src/util/nostr"
import {DUFFLEPUD_URL, DEFAULT_RELAYS, SEARCH_RELAYS, FORCE_RELAYS} from "src/system/env"
import {Table, watch} from "src/util/loki"
import type {System} from "src/system/system"
import type {Table} from "src/util/loki"
import type {Sync} from "src/system/components/Sync"
import type {Relay, RelayInfo, RelayPolicy} from "src/system/types"
export type RoutingOpts = {
getDefaultRelays: () => string[]
relayHasError: (url: string) => boolean
}
export class Routing {
system: System
sync: Sync
relays: Table<Relay>
policies: Table<RelayPolicy>
searchRelays: Readable<(query: string) => Relay[]>
constructor(system) {
this.system = system
constructor(sync, readonly opts: RoutingOpts) {
this.sync = sync
this.relays = sync.table("routing/relays", "url", {sort: sortBy(e => -e.count)})
this.policies = sync.table("routing/policies", "pubkey", {sort: sync.sortByPubkeyWhitelist})
this.searchRelays = this.relays.watch(() => fuzzy(this.relays.all(), {keys: ["url"]}))
this.relays = new Table(system.key("routing/relays"), "url", {sort: sortBy(e => -e.count)})
this.policies = new Table(system.key("routing/policies"), "pubkey", {sort: system.sortByGraph})
this.searchRelays = watch(this.relays, () => fuzzy(this.relays.all(), {keys: ["url"]}))
system.sync.addHandler(2, e => {
sync.addHandler(2, e => {
if (isShareableRelay(e.content)) {
this.addRelay(normalizeRelayUrl(e.content))
}
})
system.sync.addHandler(3, e => {
sync.addHandler(3, e => {
this.setPolicy(
e,
tryJson(() => {
@ -47,7 +47,7 @@ export class Routing {
)
})
system.sync.addHandler(10002, e => {
sync.addHandler(10002, e => {
this.setPolicy(
e,
Tags.from(e)
@ -66,11 +66,13 @@ export class Routing {
)
})
;(async () => {
const {DEFAULT_RELAYS, FORCE_RELAYS, DUFFLEPUD_URL} = this.sync.env
// Throw some hardcoded defaults in there
DEFAULT_RELAYS.forEach(this.addRelay)
// Load relays from nostr.watch via dufflepud
if (FORCE_RELAYS.length === 0) {
if (FORCE_RELAYS.length === 0 && DUFFLEPUD_URL) {
try {
const json = await fetchJson(DUFFLEPUD_URL + "/relay")
@ -126,7 +128,7 @@ export class Routing {
this.relays.all({"info.supported_nips": {$contains: 50}})
)
return uniq(SEARCH_RELAYS.concat(searchableRelayUrls)).slice(0, 8)
return uniq(this.sync.env.SEARCH_RELAYS.concat(searchableRelayUrls)).slice(0, 8)
}
getPubkeyRelays = (pubkey, mode = null) => {
@ -156,7 +158,7 @@ export class Routing {
const ok = []
const bad = []
for (const url of chain(hints, this.system.user.getRelayUrls(), DEFAULT_RELAYS)) {
for (const url of chain(hints, this.opts.getDefaultRelays())) {
if (seen.has(url)) {
continue
}
@ -166,9 +168,7 @@ export class Routing {
// Filter out relays that appear to be broken or slow
if (!isShareableRelay(url)) {
bad.push(url)
} else if (this.system.network.pool.get(url, {autoConnect: false})?.error) {
bad.push(url)
} else if (first(this.system.meta.getRelayQuality(url)) < 0.5) {
} else if (this.opts.relayHasError(url)) {
bad.push(url)
} else {
ok.push(url)
@ -191,8 +191,8 @@ export class Routing {
getPubkeyHints = this.hintSelector(function* (pubkey, mode = "write") {
const other = mode === "write" ? "read" : "write"
yield* this.system.routing.getPubkeyRelayUrls(pubkey, mode)
yield* this.system.routing.getPubkeyRelayUrls(pubkey, other)
yield* this.getPubkeyRelayUrls(pubkey, mode)
yield* this.getPubkeyRelayUrls(pubkey, other)
})
getEventHints = this.hintSelector(function* (event) {
@ -205,9 +205,9 @@ export class Routing {
// will write replies there. However, this may include spam, so we may want
// to read from the current user's network's read relays instead.
getReplyHints = this.hintSelector(function* (event) {
yield* this.system.routing.getPubkeyRelayUrls(event.pubkey, "write")
yield* this.getPubkeyRelayUrls(event.pubkey, "write")
yield* event.seen_on || []
yield* this.system.routing.getPubkeyRelayUrls(event.pubkey, "read")
yield* this.getPubkeyRelayUrls(event.pubkey, "read")
})
// If we're looking for an event's parent, tags are the most reliable hint,
@ -224,17 +224,12 @@ export class Routing {
// anyone else who is tagged in the original event or the reply. Get everyone's read
// relays. Limit how many per pubkey we publish to though. We also want to advertise
// our content to our followers, so publish to our write relays as well.
getPublishHints = (limit, event) => {
getPublishHints = (limit, event, extraRelays = []) => {
const tags = Tags.from(event)
const pubkeys = tags.type("p").values().all().concat(event.pubkey)
const hints = this.mergeHints(
limit,
pubkeys.map(pubkey => this.getPubkeyHints(3, pubkey, "read"))
)
const hintGroups = pubkeys.map(pubkey => this.getPubkeyHints(3, pubkey, "read"))
return uniq(
hints.concat(this.system.routing.getPubkeyRelayUrls(this.system.user.getStateKey(), "write"))
)
return this.mergeHints(limit, hintGroups.concat([extraRelays]))
}
mergeHints = (limit, groups) => {

View File

@ -1,22 +1,22 @@
import {ensurePlural} from "hurdak/lib/hurdak"
import {now} from "src/util/misc"
import {Tags} from "src/util/nostr"
import {Table} from "src/util/loki"
import type {System} from "src/system/system"
import type {Table} from "src/util/loki"
import type {Sync} from "src/system/components/Sync"
import type {GraphEntry} from "src/system/types"
export class Social {
system: System
sync: Sync
graph: Table<GraphEntry>
constructor(system) {
this.system = system
constructor(sync) {
this.sync = sync
this.graph = new Table(this.system.key("social/graph"), "pubkey", {
this.graph = sync.table("social/graph", "pubkey", {
max: 5000,
sort: this.system.sortByGraph,
sort: sync.sortByPubkeyWhitelist,
})
this.system.sync.addHandler(3, e => {
sync.addHandler(3, e => {
const entry = this.graph.get(e.pubkey)
if (e.created_at < entry?.petnames_updated_at) {
@ -31,7 +31,7 @@ export class Social {
})
})
this.system.sync.addHandler(10000, e => {
sync.addHandler(10000, e => {
const entry = this.graph.get(e.pubkey)
if (e.created_at < entry?.mutes_updated_at) {

View File

@ -1,41 +0,0 @@
import {identity} from "ramda"
import {ensurePlural, chunk} from "hurdak/lib/hurdak"
import {sleep} from "src/util/misc"
import type {System} from "src/system/system"
export class Sync {
handlers = {}
system: System
ANY_KIND: string
constructor(system) {
this.system = system
this.ANY_KIND = this.system.key("ANY_KIND")
}
addHandler(kind, f) {
this.handlers[kind] = this.handlers[kind] || []
this.handlers[kind].push(f)
}
async processEvents(events) {
const chunks = chunk(100, ensurePlural(events).filter(identity))
for (let i = 0; i < chunks.length; i++) {
for (const event of chunks[i]) {
for (const handler of this.handlers[this.ANY_KIND] || []) {
await handler(event)
}
for (const handler of this.handlers[event.kind] || []) {
await handler(event)
}
}
// Don't lock up the ui when processing a lot of events
if (i < chunks.length - 1) {
await sleep(30)
}
}
}
}

View File

@ -1,71 +1,181 @@
import {prop, nth, sortBy} from "ramda"
import {User} from "src/system/user"
import {Sync} from "src/system/sync"
import {Social} from "src/system/social"
import {Directory} from "src/system/directory"
import {Nip05} from "src/system/nip05"
import {Nip57} from "src/system/nip57"
import {Content} from "src/system/content"
import {Routing} from "src/system/routing"
import {Cache} from "src/system/cache"
import {Chat} from "src/system/chat"
import {Alerts} from "src/system/alerts"
import {Network} from "src/system/network"
import {Outbox} from "src/system/outbox"
import {Builder} from "src/system/builder"
import {Meta} from "src/system/meta"
import {first} from "hurdak/lib/hurdak"
import {Sync} from "src/system/components/Sync"
import {Network} from "src/system/components/Network"
import {Meta} from "src/system/components/Meta"
import {User} from "src/system/components/User"
import {Directory} from "src/system/stores/Directory"
import {Routing} from "src/system/stores/Routing"
import {Content} from "src/system/stores/Content"
import {Nip57} from "src/system/stores/Nip57"
import {Chat} from "src/system/stores/Chat"
import {Nip05} from "src/system/stores/Nip05"
import {Cache} from "src/system/stores/Cache"
import {Social} from "src/system/stores/Social"
import {Alerts} from "src/system/stores/Alerts"
import {PubkeyLoader} from "src/system/util/PubkeyLoader"
import {Builder} from "src/system/util/Builder"
export class System {
export type SystemEnv = {
DUFFLEPUD_URL?: string
MULTIPLEXTR_URL?: string
FORCE_RELAYS?: string[]
COUNT_RELAYS: string[]
SEARCH_RELAYS: string[]
DEFAULT_RELAYS: string[]
}
export interface System {
ns: string
user: User
env: SystemEnv
sync: Sync
social: Social
network: Network
meta: Meta
user: User
cache: Cache
content: Content
directory: Directory
nip05: Nip05
nip57: Nip57
social: Social
routing: Routing
cache: Cache
chat: Chat
alerts: Alerts
content: Content
network: Network
chat: Chat
builder: Builder
outbox: Outbox
meta: Meta
constructor(ns) {
this.ns = ns
this.sync = new Sync(this)
this.user = new User(this)
this.social = new Social(this)
this.directory = new Directory(this)
this.nip05 = new Nip05(this)
this.nip57 = new Nip57(this)
this.routing = new Routing(this)
this.cache = new Cache(this)
this.chat = new Chat(this)
this.alerts = new Alerts(this)
this.content = new Content(this)
this.network = new Network(this)
this.builder = new Builder(this)
this.outbox = new Outbox(this)
this.meta = new Meta(this)
}
key = key => `${this.ns}/${key}`
// For use with table.sort to avoid deleting the user's own info or those of
// direct follows
sortByGraph = xs => {
const pubkey = this.user.getPubkey()
if (pubkey) {
const follows = this.social.graph.get(pubkey).petnames || []
const whitelist = new Set(follows.map(nth(1)).concat(pubkey))
return sortBy(x => (whitelist.has(x.pubkey) ? 0 : x.updated_at), xs)
} else {
return sortBy(prop("updated_at"))
}
}
pubkeyLoader: PubkeyLoader
}
export class DefaultSystem implements System {
ns: string
env: SystemEnv
sync: Sync
network: Network
meta: Meta
user: User
cache: Cache
content: Content
directory: Directory
nip05: Nip05
nip57: Nip57
social: Social
routing: Routing
alerts: Alerts
chat: Chat
builder: Builder
pubkeyLoader: PubkeyLoader
constructor(ns, env) {
this.ns = ns
this.env = env
// Core components
this.sync = DefaultSystem.getSync(this)
this.network = DefaultSystem.getNetwork(this)
this.meta = DefaultSystem.getMeta(this)
this.user = DefaultSystem.getUser(this)
// Data stores
this.cache = DefaultSystem.getCache(this)
this.content = DefaultSystem.getContent(this)
this.directory = DefaultSystem.getDirectory(this)
this.nip05 = DefaultSystem.getNip05(this)
this.nip57 = DefaultSystem.getNip57(this)
this.social = DefaultSystem.getSocial(this)
this.routing = DefaultSystem.getRouting(this)
this.alerts = DefaultSystem.getAlerts(this)
this.chat = DefaultSystem.getChat(this)
// Extra utils
this.builder = DefaultSystem.getBuilder(this)
this.pubkeyLoader = DefaultSystem.getPubkeyLoader(this)
}
static getSync = system =>
new Sync(system, {
getUserPubkey: () => system.user.getPubkey(),
getPubkeyWhitelist: () => {
const pubkey = system.user.getPubkey()
if (!pubkey) {
return null
}
const follows = system.user.getFollowsSet()
follows.add(pubkey)
return follows
},
})
static getNetwork = system =>
new Network({
getMultiplextrUrl: () => system.user.getSetting("multiplextr_url"),
processEvents: events => system.sync.processEvents(events),
getRelayInfo: url => system.routing.getRelayInfo(url),
forceRelays: system.env.FORCE_RELAYS,
countRelays: system.env.COUNT_RELAYS,
})
static getMeta = system =>
new Meta({
network: system.network,
})
static getUser = system => new User(system)
static getCache = system => new Cache(system.sync)
static getContent = system => new Content(system.sync)
static getDirectory = system => new Directory(system.sync)
static getNip05 = system => new Nip05(system.sync)
static getNip57 = system => new Nip57(system.sync)
static getSocial = system => new Social(system.sync)
static getRouting = system =>
new Routing(system.sync, {
getDefaultRelays: () => system.user.getRelayUrls().concat(system.env.DEFAULT_RELAYS),
relayHasError: url =>
Boolean(
system.network.pool.get(url, {autoConnect: false})?.error ||
first(system.meta.getRelayQuality(url)) < 0.5
),
})
static getAlerts = system =>
new Alerts(system.sync, {
getUserPubkey: () => system.user.getPubkey(),
isUserEvent: e => system.user.isUserEvent(e),
isMuted: e => system.user.isMuted(e),
})
static getChat = system =>
new Chat(system.sync, {
getCrypt: () => system.user.crypt,
})
static getBuilder = system =>
new Builder({
getEventHint: event => first(system.routing.getEventHints(1, event)) || "",
getPubkeyHint: pubkey => first(system.routing.getPubkeyHints(1, pubkey)) || "",
getPubkeyPetname: pubkey => {
const profile = system.directory.getProfile(pubkey)
return profile ? system.directory.displayProfile(profile) : ""
},
})
static getPubkeyLoader = system =>
new PubkeyLoader({
getLastUpdated: pubkey => system.directory.profiles.get(pubkey)?.updated_at || 0,
getChunkRelays: pubkeys =>
system.routing.mergeHints(
system.user.getSetting("relay_limit"),
pubkeys.map(pubkey => system.routing.getPubkeyHints(3, pubkey))
),
loadChunk: ({filter, relays}) => system.network.load({filter, relays}),
})
}

View File

@ -15,6 +15,25 @@ export type Filter = {
[key: `#${string}`]: string[]
}
export type Zapper = {
pubkey: string
lnurl: string
callback: string
minSendable: number
maxSendable: number
nostrPubkey: string
created_at: number
updated_at: number
}
export type Handle = {
profile: Record<string, any>
pubkey: string
address: string
created_at: number
updated_at: number
}
export type RelayInfo = {
contact?: string
description?: string
@ -92,3 +111,24 @@ export type KeyState = {
privkey?: string
bunkerKey?: string
}
export type Channel = {
id: string
type: "public" | "private"
pubkey: string
updated_at: number
last_sent?: number
last_received?: number
last_checked?: number
joined?: boolean
hints: string[]
}
export type Message = {
id: string
channel: string
pubkey: string
created_at: number
content: string
tags: string[][]
}

View File

@ -1,8 +1,13 @@
import {last, pick, uniqBy} from "ramda"
import {doPipe, first} from "hurdak/lib/hurdak"
import {doPipe} from "hurdak/lib/hurdak"
import {Tags, channelAttrs, findRoot, findReply} from "src/util/nostr"
import {parseContent} from "src/util/notes"
import type {System} from "src/system/system"
export type BuilderOpts = {
getPubkeyPetname: (pubkey: string) => string
getPubkeyHint: (pubkey: string) => string
getEventHint: (event: Event) => string
}
const uniqTags = uniqBy(t => t.slice(0, 2).join(":"))
@ -15,16 +20,19 @@ const buildEvent = (kind, {content = "", tags = [], tagClient = true}) => {
}
export class Builder {
system: System
constructor(system) {
this.system = system
getPubkeyPetname: BuilderOpts["getPubkeyPetname"]
getPubkeyHint: BuilderOpts["getPubkeyHint"]
getEventHint: BuilderOpts["getEventHint"]
constructor({getPubkeyPetname, getPubkeyHint, getEventHint}: BuilderOpts) {
this.getPubkeyPetname = getPubkeyPetname
this.getPubkeyHint = getPubkeyHint
this.getEventHint = getEventHint
}
mention = pubkey => {
const profile = this.system.directory.getProfile(pubkey)
const name = profile ? this.system.directory.displayProfile(profile) : ""
const hint = first(this.system.routing.getPubkeyHints(1, pubkey)) || ""
const hint = this.getPubkeyHint(pubkey)
const petname = this.getPubkeyPetname(pubkey)
return ["p", pubkey, hint, name]
return ["p", pubkey, hint, petname]
}
tagsFromContent(content, tags) {
const seen = new Set(Tags.wrap(tags).values().all())
@ -56,7 +64,7 @@ export class Builder {
.all()
.map(t => t.slice(0, 3))
: []
const eHint = first(this.system.routing.getEventHints(1, n)) || ""
const eHint = this.getEventHint(n)
const reply = ["e", n.id, eHint, "reply"]
const root = doPipe(findRoot(n) || findReply(n) || reply, [
t => (t.length < 3 ? t.concat(eHint) : t),

View File

@ -3,7 +3,12 @@ import {chunk} from "hurdak/lib/hurdak"
import {personKinds, appDataKeys} from "src/util/nostr"
import {now, timedelta} from "src/util/misc"
import type {Filter} from "src/system/types"
import type {System} from "src/system/system"
export type PubkeyLoaderOpts = {
getLastUpdated: (pubkey: string) => number
getChunkRelays: (pubkeys: string[]) => string[]
loadChunk: (args: {filter: Filter | Filter[]; relays: string[]}) => Promise<void>
}
export type LoadPeopleOpts = {
relays?: string[]
@ -12,12 +17,16 @@ export type LoadPeopleOpts = {
}
export class PubkeyLoader {
system: System
attemptedPubkeys: Set<string>
getLastUpdated: PubkeyLoaderOpts["getLastUpdated"]
getChunkRelays: PubkeyLoaderOpts["getChunkRelays"]
loadChunk: PubkeyLoaderOpts["loadChunk"]
constructor(system) {
this.system = system
constructor({getLastUpdated, getChunkRelays, loadChunk}: PubkeyLoaderOpts) {
this.attemptedPubkeys = new Set()
this.getLastUpdated = getLastUpdated
this.getChunkRelays = getChunkRelays
this.loadChunk = loadChunk
}
getStalePubkeys = pubkeys => {
@ -31,9 +40,7 @@ export class PubkeyLoader {
this.attemptedPubkeys.add(pubkey)
const profile = this.system.directory.profiles.get(pubkey)
if (profile?.updated_at > since) {
if (this.getLastUpdated(pubkey) > since) {
continue
}
@ -44,7 +51,6 @@ export class PubkeyLoader {
}
loadPubkeys = async (rawPubkeys, {relays, force, kinds = personKinds}: LoadPeopleOpts = {}) => {
const {network, routing, user} = this.system
const pubkeys = force ? uniq(rawPubkeys) : this.getStalePubkeys(rawPubkeys)
const getChunkRelays = chunk => {
@ -52,10 +58,7 @@ export class PubkeyLoader {
return relays
}
return routing.mergeHints(
user.getSetting("relay_limit"),
chunk.map(pubkey => routing.getPubkeyHints(3, pubkey))
)
return this.getChunkRelays(chunk)
}
const getChunkFilter = chunk => {
@ -74,7 +77,7 @@ export class PubkeyLoader {
await Promise.all(
chunk(256, pubkeys).map(async chunk => {
await network.load({
await this.loadChunk({
relays: getChunkRelays(chunk),
filter: getChunkFilter(chunk),
})

View File

@ -138,6 +138,9 @@ export class Table<T> {
max(k): number {
return this._coll.max(k)
}
watch(f) {
return watch(this, f)
}
}
const listener = (() => {