Delete system

This commit is contained in:
Jonathan Staab 2023-07-12 13:46:40 -07:00
parent 8de6980802
commit 1f3fee870a
30 changed files with 12 additions and 2282 deletions

View File

@ -1,6 +1,6 @@
import {Tags, isLike, findReplyId, findRootId} from "src/util/nostr" import {Tags, isLike, findReplyId, findRootId} from "src/util/nostr"
import {collection, writable, derived} from "../util/store" import {collection, writable, derived} from "../util/store"
import type {Event} from "src/system/types" import type {Event} from "src/engine/types"
export function contributeState() { export function contributeState() {
const events = collection<Event>() const events = collection<Event>()

View File

@ -1,7 +1,7 @@
import {last, propEq, pick, uniq, pluck} from "ramda" import {last, propEq, pick, uniq, pluck} from "ramda"
import {tryJson, now, tryFunc} from "src/util/misc" import {tryJson, now, tryFunc} from "src/util/misc"
import {Tags, channelAttrs} from "src/util/nostr" import {Tags, channelAttrs} from "src/util/nostr"
import type {Channel, Message} from "src/system/types" import type {Channel, Message} from "src/engine/types"
import {collection, derived} from "../util/store" import {collection, derived} from "../util/store"
const getHints = e => pluck("url", Tags.from(e).relays()) const getHints = e => pluck("url", Tags.from(e).relays())

View File

@ -2,7 +2,7 @@ import {nip19} from "nostr-tools"
import {always, nth, inc} from "ramda" import {always, nth, inc} from "ramda"
import {fuzzy} from "src/util/misc" import {fuzzy} from "src/util/misc"
import {Tags} from "src/util/nostr" import {Tags} from "src/util/nostr"
import type {Topic, List} from "src/system/types" import type {Topic, List} from "src/engine/types"
import {derived, collection} from "../util/store" import {derived, collection} from "../util/store"
export function contributeState() { export function contributeState() {

View File

@ -1,7 +1,7 @@
import {nip19} from "nostr-tools" import {nip19} from "nostr-tools"
import {ellipsize} from "hurdak/lib/hurdak" import {ellipsize} from "hurdak/lib/hurdak"
import {tryJson, now, fuzzy} from "src/util/misc" import {tryJson, now, fuzzy} from "src/util/misc"
import type {Profile} from "src/system/types" import type {Profile} from "src/engine/types"
import {collection, derived} from "../util/store" import {collection, derived} from "../util/store"
export function contributeState() { export function contributeState() {

View File

@ -1,7 +1,7 @@
import {Socket} from "paravel" import {Socket} from "paravel"
import {now} from "src/util/misc" import {now} from "src/util/misc"
import {switcher} from "hurdak/lib/hurdak" import {switcher} from "hurdak/lib/hurdak"
import type {RelayStat} from "src/system/types" import type {RelayStat} from "src/engine/types"
import {collection} from "../util/store" import {collection} from "../util/store"
export function contributeState() { export function contributeState() {

View File

@ -5,7 +5,7 @@ import {ensurePlural} from "hurdak/lib/hurdak"
import {union, difference} from "src/util/misc" import {union, difference} from "src/util/misc"
import {warn, error, log} from "src/util/logger" import {warn, error, log} from "src/util/logger"
import {normalizeRelayUrl} from "src/util/nostr" import {normalizeRelayUrl} from "src/util/nostr"
import type {Event, Filter} from "src/system/types" import type {Event, Filter} from "src/engine/types"
export type SubscribeOpts = { export type SubscribeOpts = {
relays: string[] relays: string[]

View File

@ -1,7 +1,7 @@
import {last} from "ramda" import {last} from "ramda"
import {nip05} from "nostr-tools" import {nip05} from "nostr-tools"
import {tryFunc, now, tryJson} from "src/util/misc" import {tryFunc, now, tryJson} from "src/util/misc"
import type {Handle} from "src/system/types" import type {Handle} from "src/engine/types"
import {collection} from "../util/store" import {collection} from "../util/store"
export function contributeState() { export function contributeState() {

View File

@ -1,7 +1,7 @@
import {fetchJson, now, tryFunc, tryJson, hexToBech32, bech32ToHex} from "src/util/misc" import {fetchJson, now, tryFunc, tryJson, hexToBech32, bech32ToHex} from "src/util/misc"
import {invoiceAmount} from "src/util/lightning" import {invoiceAmount} from "src/util/lightning"
import {Tags} from "src/util/nostr" import {Tags} from "src/util/nostr"
import type {Zapper} from "src/system/types" import type {Zapper} from "src/engine/types"
import {collection} from "../util/store" import {collection} from "../util/store"
const getLnUrl = address => { const getLnUrl = address => {

View File

@ -2,7 +2,7 @@ import {without, uniq} from "ramda"
import {chunk} from "hurdak/lib/hurdak" import {chunk} from "hurdak/lib/hurdak"
import {personKinds, appDataKeys} from "src/util/nostr" import {personKinds, appDataKeys} from "src/util/nostr"
import {now, timedelta} from "src/util/misc" import {now, timedelta} from "src/util/misc"
import type {Filter} from "src/system/types" import type {Filter} from "src/engine/types"
export type LoadPeopleOpts = { export type LoadPeopleOpts = {
relays?: string[] relays?: string[]

View File

@ -2,7 +2,7 @@ import {sortBy, pluck, uniq, nth, uniqBy, prop, last, inc} from "ramda"
import {fuzzy, chain, tryJson, now, fetchJson} from "src/util/misc" import {fuzzy, chain, tryJson, now, fetchJson} from "src/util/misc"
import {warn} from "src/util/logger" import {warn} from "src/util/logger"
import {normalizeRelayUrl, findReplyId, isShareableRelay, Tags} from "src/util/nostr" import {normalizeRelayUrl, findReplyId, isShareableRelay, Tags} from "src/util/nostr"
import type {Relay, RelayInfo, RelayPolicy} from "src/system/types" import type {Relay, RelayInfo, RelayPolicy} from "src/engine/types"
import {derived, collection} from "../util/store" import {derived, collection} from "../util/store"
export function contributeState() { export function contributeState() {

View File

@ -1,7 +1,7 @@
import {ensurePlural} from "hurdak/lib/hurdak" import {ensurePlural} from "hurdak/lib/hurdak"
import {now} from "src/util/misc" import {now} from "src/util/misc"
import {Tags} from "src/util/nostr" import {Tags} from "src/util/nostr"
import type {GraphEntry} from "src/system/types" import type {GraphEntry} from "src/engine/types"
import {collection} from "../util/store" import {collection} from "../util/store"
export function contributeState() { export function contributeState() {

View File

@ -1,4 +1,4 @@
import type {Event} from "src/system/types" import type {Event} from "src/engine/types"
import {pushToKey} from "src/util/misc" import {pushToKey} from "src/util/misc"
import {queue} from "../util/queue" import {queue} from "../util/queue"
import {collection} from "../util/store" import {collection} from "../util/store"

View File

@ -1,144 +0,0 @@
import {Socket} from "paravel"
import {now} from "src/util/misc"
import {switcher} from "hurdak/lib/hurdak"
import type {RelayStat} from "src/system/types"
import type {Network} from "src/system/components/Network"
export type MetaOpts = {
network: Network
}
export class Meta {
network: Network
relayStats: Record<string, RelayStat>
constructor({network}: MetaOpts) {
this.network = network
this.relayStats = {}
network.pool.on("open", ({url}) => {
this.updateRelayStats(url, {last_opened: now(), last_activity: now()})
})
network.pool.on("close", ({url}) => {
this.updateRelayStats(url, {last_closed: now(), last_activity: now()})
})
network.pool.on("error:set", (url, error) => {
this.updateRelayStats(url, {error})
})
network.pool.on("error:clear", url => {
this.updateRelayStats(url, {error: null})
})
network.on("publish", urls => {
for (const url of urls) {
this.updateRelayStats(url, {
last_publish: now(),
last_activity: now(),
})
}
})
network.on("sub:open", urls => {
for (const url of urls) {
const stats = this.getRelayStats(url)
this.updateRelayStats(url, {
last_sub: now(),
last_activity: now(),
total_subs: (stats?.total_subs || 0) + 1,
active_subs: (stats?.active_subs || 0) + 1,
})
}
})
network.on("sub:close", urls => {
for (const url of urls) {
const stats = this.getRelayStats(url)
this.updateRelayStats(url, {
last_activity: now(),
active_subs: stats ? stats.active_subs - 1 : 0,
})
}
})
network.on("event", ({url}) => {
const stats = this.getRelayStats(url)
this.updateRelayStats(url, {
last_activity: now(),
events_count: (stats.events_count || 0) + 1,
})
})
network.on("eose", (url, ms) => {
const stats = this.getRelayStats(url)
this.updateRelayStats(url, {
last_activity: now(),
eose_count: (stats.eose_count || 0) + 1,
eose_timer: (stats.eose_timer || 0) + ms,
})
})
network.on("timeout", (url, ms) => {
const stats = this.getRelayStats(url)
this.updateRelayStats(url, {
last_activity: now(),
timeouts: (stats.timeouts || 0) + 1,
})
})
}
getRelayStats = url => this.relayStats[url]
updateRelayStats = (url, updates) => {
const stats = this.getRelayStats(url)
this.relayStats[url] = {...stats, ...updates}
}
getRelayQuality = url => {
const stats = this.getRelayStats(url)
if (!stats) {
return [0.5, "Not Connected"]
}
if (stats.error) {
return [
0,
switcher(stats.error, {
disconnected: "Disconnected",
unauthorized: "Logging in",
forbidden: "Failed to log in",
}),
]
}
const {timeouts, total_subs: totalSubs, eose_timer: eoseTimer, eose_count: eoseCount} = stats
const timeoutRate = timeouts > 0 ? timeouts / totalSubs : null
const eoseQuality = eoseCount > 0 ? Math.max(1, 500 / (eoseTimer / eoseCount)) : null
if (timeoutRate && timeoutRate > 0.5) {
return [1 - timeoutRate, "Slow connection"]
}
if (eoseQuality && eoseQuality < 0.7) {
return [eoseQuality, "Slow connection"]
}
if (eoseQuality) {
return [eoseQuality, "Connected"]
}
if (this.network.pool.get(url).status === Socket.STATUS.READY) {
return [1, "Connected"]
}
return [0.5, "Not Connected"]
}
}

View File

@ -1,334 +0,0 @@
import {EventEmitter} from "events"
import {verifySignature, matchFilters} from "nostr-tools"
import {Pool, Plex, Relays, Executor, Socket} from "paravel"
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 type {Event, Filter, RelayInfo} from "src/system/types"
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: Event) => void
onEose?: (url: string) => void
shouldProcess?: boolean
}
const getUrls = 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)
}
export class Network extends EventEmitter {
authHandler?: (url: string, challenge: string) => void
pool: Pool
constructor(readonly opts: NetworkOpts) {
super()
this.authHandler = null
this.pool = new Pool()
}
getExecutor = (urls, {bypassBoot = false} = {}) => {
if (this.opts.forceRelays.length > 0) {
urls = this.opts.forceRelays
}
let target
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
// 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 socket = this.pool.get(muxUrl)
if (!socket.error) {
target = new Plex(urls, socket)
}
}
if (!target) {
target = new Relays(urls.map(url => this.pool.get(url)))
}
const executor = new Executor(target)
executor.handleAuth({
onAuth(url, challenge) {
this.emit("error:set", url, "unauthorized")
return this.authHandler?.(url, challenge)
},
onOk(url, id, ok, message) {
this.emit("error:clear", url, ok ? null : "forbidden")
// Once we get a good auth response don't wait to send stuff to the relay
if (ok) {
this.pool.get(url)
this.pool.booted = true
}
},
})
// Eagerly connect and handle AUTH
executor.target.sockets.forEach(socket => {
const {limitation} = this.opts.getRelayInfo(socket.url)
const waitForBoot = limitation?.payment_required || limitation?.auth_required
// This happens automatically, but kick it off anyway
socket.connect()
// Delay REQ/EVENT until AUTH flow happens. Highly hacky, as this relies on
// overriding the `shouldDeferWork` property of the socket. We do it this way
// so that we're not blocking sending to all the other public relays
if (!bypassBoot && waitForBoot && socket.status === Socket.STATUS.PENDING) {
socket.shouldDeferWork = () => {
return socket.booted && socket.status !== Socket.STATUS.READY
}
setTimeout(() => Object.assign(socket, {booted: true}), 2000)
}
})
return executor
}
publish = ({relays, event, onProgress, timeout = 3000, verb = "EVENT"}) => {
const urls = getUrls(relays)
const executor = this.getExecutor(urls, {bypassBoot: verb === "AUTH"})
this.emit("publish", urls)
log(`Publishing to ${urls.length} relays`, event, urls)
return new Promise(resolve => {
const timeouts = new Set()
const succeeded = new Set()
const failed = new Set()
const getProgress = () => {
const completed = union(timeouts, succeeded, failed)
const pending = difference(urls, completed)
return {succeeded, failed, timeouts, completed, pending}
}
const attemptToResolve = () => {
const progress = getProgress()
if (progress.pending.size === 0) {
log(`Finished publishing to ${urls.length} relays`, event, progress)
resolve(progress)
sub.unsubscribe()
executor.target.cleanup()
} else if (onProgress) {
onProgress(progress)
}
}
setTimeout(() => {
for (const url of urls) {
if (!succeeded.has(url) && !failed.has(url)) {
timeouts.add(url)
}
}
attemptToResolve()
}, timeout)
const sub = executor.publish(event, {
verb,
onOk: url => {
succeeded.add(url)
timeouts.delete(url)
failed.delete(url)
attemptToResolve()
},
onError: url => {
failed.add(url)
timeouts.delete(url)
attemptToResolve()
},
})
// Report progress to start
attemptToResolve()
})
}
subscribe = ({relays, filter, onEvent, onEose, shouldProcess = true}: SubscribeOpts) => {
const urls = getUrls(relays)
const executor = this.getExecutor(urls)
const filters = ensurePlural(filter)
const now = Date.now()
const seen = new Map()
const eose = new Set()
log(`Starting subscription with ${relays.length} relays`, {filters, relays})
this.emit("sub:open", urls)
const sub = executor.subscribe(filters, {
onEvent: (url, event) => {
const seen_on = seen.get(event.id)
if (seen_on) {
if (!seen_on.includes(url)) {
seen_on.push(url)
}
return
}
Object.assign(event, {
seen_on: [url],
content: event.content || "",
})
seen.set(event.id, event.seen_on)
try {
if (!verifySignature(event)) {
return
}
} catch (e) {
console.error(e)
return
}
if (!matchFilters(filters, event)) {
return
}
this.emit("event", {url, event})
if (shouldProcess) {
this.opts.processEvents([event])
}
onEvent(event)
},
onEose: url => {
onEose?.(url)
// Keep track of relay timing stats, but only for the first eose we get
if (!eose.has(url)) {
this.emit("eose", url, Date.now() - now)
}
eose.add(url)
},
})
let closed = false
return () => {
if (closed) {
error("Closed subscription twice", filters)
} else {
log(`Closing subscription`, filters)
}
sub.unsubscribe()
executor.target.cleanup()
this.emit("sub:close", urls)
closed = true
}
}
load = ({
relays,
filter,
onEvent = null,
shouldProcess = true,
timeout = 5000,
}: {
relays: string[]
filter: Filter | Filter[]
onEvent?: (event: Event) => void
shouldProcess?: boolean
timeout?: number
}) => {
return new Promise(resolve => {
let completed = false
const eose = new Set()
const allEvents = []
const attemptToComplete = force => {
// If we've already unsubscribed we're good
if (completed) {
return
}
const isDone = eose.size === relays.length
if (force) {
relays.forEach(url => {
if (!eose.has(url)) {
this.pool.emit("timeout", url)
}
})
}
if (isDone || force) {
unsubscribe()
resolve(allEvents)
completed = true
}
}
// If a relay takes too long, give up
setTimeout(() => attemptToComplete(true), timeout)
const unsubscribe = this.subscribe({
relays,
filter,
shouldProcess,
onEvent: event => {
onEvent?.(event)
allEvents.push(event)
},
onEose: url => {
eose.add(url)
attemptToComplete(false)
},
})
}) as Promise<Event[]>
}
count = async filter => {
const filters = ensurePlural(filter)
const executor = this.getExecutor(this.opts.countRelays)
return new Promise(resolve => {
const sub = executor.count(filters, {
onCount: (url, {count}) => resolve(count),
})
setTimeout(() => {
resolve(0)
sub.unsubscribe()
executor.target.cleanup()
}, 3000)
})
}
}

View File

@ -1,70 +0,0 @@
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,254 +0,0 @@
import {getEventHash} from "nostr-tools"
import {when, uniq, pluck, without, fromPairs, whereEq, find, slice, assoc, reject} from "ramda"
import {doPipe} from "hurdak/lib/hurdak"
import {now, getter} from "src/util/misc"
import {Tags, normalizeRelayUrl, findReplyId, findRootId} from "src/util/nostr"
import type {System} from "src/system/System"
import type {Writable} from "svelte/store"
import engine from "src/app/system"
export type UserSettings = {
last_updated: number
relay_limit: number
default_zap: number
show_media: boolean
report_analytics: boolean
dufflepud_url: string
multiplextr_url: string
}
export class User {
keys: typeof engine.keys
crypt: typeof engine.crypt
system: System
canSign: () => boolean
settings: Writable<UserSettings>
getSettings: () => UserSettings
constructor(system) {
this.system = system
this.keys = engine.keys
this.crypt = engine.crypt
this.canSign = getter(this.keys.canSign)
this.settings = system.sync.store("settings/settings", {
last_updated: 0,
relay_limit: 10,
default_zap: 21,
show_media: true,
report_analytics: true,
dufflepud_url: system.env.DUFFLEPUD_URL,
multiplextr_url: system.env.MULTIPLEXTR_URL,
})
this.getSettings = getter(this.settings)
system.sync.addHandler(30078, async e => {
if (
Tags.from(e).getMeta("d") === "coracle/settings/v1" &&
e.created_at > this.getSetting("last_updated")
) {
const updates = await this.crypt.decryptJson(e.content)
if (updates) {
this.settings.set({...this.getSettings(), ...updates, lastUpdated: e.created_at})
}
}
})
}
getPubkey = () => this.keys.pubkey.get()
getProfile = () => this.system.directory.getProfile(this.getPubkey())
isUserEvent = id => this.system.cache.events.get(id)?.pubkey === this.getPubkey()
getStateKey = () => (this.canSign() ? this.getPubkey() : "anonymous")
// 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
getSetting = k => this.getSettings()[k]
dufflepud = path => `${this.getSetting("dufflepud_url")}/${path}`
setSettings = async settings => {
this.settings.update($settings => ({...$settings, ...settings}))
if (this.canSign()) {
const d = "coracle/settings/v1"
const v = await this.crypt.encryptJson(settings)
return this.publish(this.system.builder.setAppData(d, v))
}
}
async setAppData(key, content) {
if (this.canSign()) {
const d = `coracle/${key}`
const v = await this.crypt.encryptJson(content)
return this.publish(this.system.builder.setAppData(d, v))
}
}
// Routing
getRelays = (mode?: string) => this.system.routing.getPubkeyRelays(this.getStateKey(), mode)
getRelayUrls = (mode?: string) => this.system.routing.getPubkeyRelayUrls(this.getStateKey(), mode)
setRelays = relays => {
if (this.canSign()) {
return this.publish(this.system.builder.setRelays(relays))
} else {
this.system.routing.setPolicy({pubkey: this.getStateKey(), created_at: now()}, relays)
}
}
addRelay = url => this.setRelays(this.getRelays().concat({url, read: true, write: true}))
removeRelay = url =>
this.setRelays(reject(whereEq({url: normalizeRelayUrl(url)}), this.getRelays()))
setRelayPolicy = (url, policy) =>
this.setRelays(this.getRelays().map(when(whereEq({url}), p => ({...p, ...policy}))))
// Social
getPetnames = () => this.system.social.getPetnames(this.getStateKey())
getMutedTags = () => this.system.social.getMutedTags(this.getStateKey())
getFollowsSet = () => this.system.social.getFollowsSet(this.getStateKey())
getMutesSet = () => this.system.social.getMutesSet(this.getStateKey())
getFollows = () => this.system.social.getFollows(this.getStateKey())
getMutes = () => this.system.social.getMutes(this.getStateKey())
getNetworkSet = () => this.system.social.getNetworkSet(this.getStateKey())
getNetwork = () => this.system.social.getNetwork(this.getStateKey())
isFollowing = pubkey => this.system.social.isFollowing(this.getStateKey(), pubkey)
isIgnoring = pubkeyOrEventId => this.system.social.isIgnoring(this.getStateKey(), pubkeyOrEventId)
setProfile = $profile => this.publish(this.system.builder.setProfile($profile))
setPetnames = async $petnames => {
if (this.canSign()) {
await this.publish(this.system.builder.setPetnames($petnames))
} else {
this.system.social.graph.patch({
pubkey: this.getStateKey(),
updated_at: now(),
petnames_updated_at: now(),
petnames: $petnames,
})
}
}
follow = pubkey =>
this.setPetnames(
this.getPetnames()
.filter(t => t[1] !== pubkey)
.concat([this.system.builder.mention(pubkey)])
)
unfollow = pubkey => this.setPetnames(reject(t => t[1] === pubkey, this.getPetnames()))
isMuted = e => {
const m = this.getMutesSet()
return find(t => m.has(t), [e.id, e.pubkey, findReplyId(e), findRootId(e)])
}
applyMutes = events => reject(this.isMuted, events)
setMutes = async $mutes => {
if (this.canSign()) {
await this.publish(this.system.builder.setMutes($mutes.map(slice(0, 2))))
} else {
this.system.social.graph.patch({
pubkey: this.getStateKey(),
updated_at: now(),
mutes_updated_at: now(),
mutes: $mutes,
})
}
}
mute = (type, value) =>
this.setMutes(reject(t => t[1] === value, this.getMutes()).concat([[type, value]]))
unmute = target => this.setMutes(reject(t => t[1] === target, this.getMutes()))
// Content
getLists = (spec = null) => this.system.content.getLists({...spec, pubkey: this.getStateKey()})
putList = (name, params, relays) =>
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,17 +0,0 @@
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,64 +0,0 @@
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

@ -1,28 +0,0 @@
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,233 +0,0 @@
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 type {Table} from "src/util/loki"
import type {Readable} from "svelte/store"
import type {Sync} from "src/system/components/Sync"
import type {Channel, Message} from "src/system/types"
import type engine from "src/app/system"
const getHints = e => pluck("url", Tags.from(e).relays())
const messageIsNew = ({last_checked, last_received, last_sent}: Channel) =>
last_received > Math.max(last_sent || 0, last_checked || 0)
export type ChatOpts = {
getCrypt: () => typeof engine.crypt
}
export class Chat {
channels: Table<Channel>
messages: Table<Message>
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
return x.updated_at
}),
})
this.messages = sync.table("chat/messages", "id", {
sort: xs => {
const channelIds = new Set(
pluck("id", this.channels.all({$or: [{joined: true}, {type: "private"}]}))
)
return sortBy(x => (channelIds.has(x.id) ? 0 : x.created_at), xs)
},
})
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) {
return
}
const content = tryJson(() => pick(channelAttrs, JSON.parse(e.content)))
if (!content?.name) {
return
}
this.channels.patch({
id: e.id,
type: "public",
pubkey: e.pubkey,
updated_at: now(),
hints: getHints(e),
...content,
})
})
sync.addHandler(41, e => {
const channelId = Tags.from(e).getMeta("e")
if (!channelId) {
return
}
const channel = this.channels.get(channelId)
if (e.created_at < channel?.updated_at) {
return
}
if (e.pubkey !== channel?.pubkey) {
return
}
const content = tryJson(() => pick(channelAttrs, JSON.parse(e.content)))
if (!content?.name) {
return
}
this.channels.patch({
id: channelId,
type: "public",
pubkey: e.pubkey,
updated_at: now(),
hints: getHints(e),
...content,
})
})
sync.addHandler(30078, async e => {
if (Tags.from(e).getMeta("d") === "coracle/last_checked/v1") {
await tryJson(async () => {
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
const channelId = last(key.split("/"))
const channel = this.channels.get(channelId)
const last_checked = Math.max(payload[channelId], channel?.last_checked || 0)
// A bunch of junk got added to this setting. Integer keys, settings, etc
if (isNaN(last_checked) || last_checked < 1577836800) {
continue
}
this.channels.patch({id: channelId, last_checked})
}
})
}
})
sync.addHandler(30078, async e => {
if (Tags.from(e).getMeta("d") === "coracle/rooms_joined/v1") {
await tryJson(async () => {
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)) {
return
}
this.channels.all({type: "public"}).forEach(channel => {
if (channel.joined && !channelIds.includes(channel.id)) {
this.channels.patch({id: channel.id, joined: false})
} else if (!channel.joined && channelIds.includes(channel.id)) {
this.channels.patch({id: channel.id, joined: true})
}
})
})
}
})
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.opts.getCrypt().keys.pubkey.get())) {
return
}
if (this.messages.get(e.id)) {
return
}
await tryFunc(async () => {
const other = this.opts.getCrypt().keys.pubkey.get() === author ? recipient : author
this.messages.patch({
id: e.id,
channel: other,
pubkey: e.pubkey,
created_at: e.created_at,
content: await this.opts.getCrypt().decrypt(other, e.content),
tags: e.tags,
})
if (this.opts.getCrypt().keys.pubkey.get() === author) {
const channel = this.channels.get(recipient)
this.channels.patch({
id: recipient,
type: "private",
last_sent: e.created_at,
hints: uniq(getHints(e).concat(channel?.hints || [])),
})
} else {
const channel = this.channels.get(author)
this.channels.patch({
id: author,
type: "private",
last_received: e.created_at,
hints: uniq(getHints(e).concat(channel?.hints || [])),
})
}
})
})
sync.addHandler(42, e => {
if (this.messages.get(e.id)) {
return
}
const tags = Tags.from(e)
const channelId = tags.getMeta("e")
const channel = this.channels.get(channelId)
const hints = uniq(pluck("url", tags.relays()).concat(channel?.hints || []))
this.messages.patch({
id: e.id,
channel: channelId,
pubkey: e.pubkey,
created_at: e.created_at,
content: e.content,
tags: e.tags,
})
this.channels.patch({
id: channelId,
type: "public",
last_sent: e.created_at,
hints,
})
})
}
}

View File

@ -1,94 +0,0 @@
import {nip19} from "nostr-tools"
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 type {Table} from "src/util/loki"
import type {Sync} from "src/system/components/Sync"
export type Topic = {
name: string
count?: number
}
export type List = {
name: string
naddr: string
pubkey: string
tags: string[][]
updated_at: number
created_at: number
deleted_at?: number
}
export class Content {
topics: Table<Topic>
lists: Table<List>
searchTopics: Readable<(query: string) => Topic[]>
constructor(sync: Sync) {
this.topics = sync.table("content/topics", "name", {sort: sortBy(e => -e.count)})
this.lists = sync.table("content/lists", "naddr")
this.searchTopics = this.topics.watch(() =>
fuzzy(this.topics.all(), {keys: ["name"], threshold: 0.3})
)
const processTopics = e => {
const tagTopics = Tags.from(e).topics()
const contentTopics = Array.from(e.content.toLowerCase().matchAll(/#(\w{2,100})/g)).map(
nth(1)
)
for (const name of tagTopics.concat(contentTopics)) {
const topic = this.topics.get(name)
this.topics.patch({name, count: inc(topic?.count || 0)})
}
}
sync.addHandler(1, processTopics)
sync.addHandler(42, processTopics)
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})
const list = this.lists.get(naddr)
if (created_at < list?.updated_at) {
return
}
this.lists.patch({
...list,
name,
naddr,
pubkey,
tags: e.tags,
updated_at: created_at,
created_at: list?.created_at || created_at,
deleted_at: null,
})
})
sync.addHandler(5, e => {
Tags.from(e)
.type("a")
.values()
.all()
.forEach(naddr => {
const list = this.lists.get(naddr)
if (list) {
this.lists.patch({
naddr: list.naddr,
deleted_at: e.created_at,
})
}
})
})
}
getLists = (spec = null) => this.lists.all({...spec, deleted_at: {$eq: null}})
}

View File

@ -1,74 +0,0 @@
import {nip19} from "nostr-tools"
import {ellipsize} from "hurdak/lib/hurdak"
import {tryJson, now, fuzzy} from "src/util/misc"
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 {
profiles: Table<Profile>
searchProfiles: Readable<(q: string) => Record<string, any>[]>
constructor(sync: Sync) {
this.profiles = sync.table("directory/profiles", "pubkey", {
max: 5000,
sort: sync.sortByPubkeyWhitelist,
})
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,
})
})
sync.addHandler(0, e => {
tryJson(() => {
const kind0 = JSON.parse(e.content)
const profile = this.profiles.get(e.pubkey)
if (e.created_at < profile?.created_at) {
return
}
this.profiles.patch({
...kind0,
pubkey: e.pubkey,
created_at: e.created_at,
updated_at: now(),
})
})
})
}
getProfile = (pubkey: string): Profile => this.profiles.get(pubkey) || {pubkey}
getNamedProfiles = () =>
this.profiles.all({
$or: [
{name: {$type: "string"}},
{nip05: {$type: "string"}},
{display_name: {$type: "string"}},
],
})
displayProfile = ({display_name, name, pubkey}: Profile) => {
if (display_name) {
return ellipsize(display_name, 60)
}
if (name) {
return ellipsize(name, 60)
}
try {
return nip19.npubEncode(pubkey).slice(-8)
} catch (e) {
console.error(e)
return ""
}
}
displayPubkey = pubkey => this.displayProfile(this.getProfile(pubkey))
}

View File

@ -1,46 +0,0 @@
import {last} from "ramda"
import {nip05} from "nostr-tools"
import {tryFunc, now, tryJson} from "src/util/misc"
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 {
handles: Table<Handle>
constructor(sync: Sync) {
this.handles = sync.table("nip05/handles", "pubkey", {
max: 5000,
sort: sync.sortByPubkeyWhitelist,
})
sync.addHandler(0, e => {
tryJson(async () => {
const kind0 = JSON.parse(e.content)
const handle = this.handles.get(e.pubkey)
if (!kind0.nip05 || e.created_at < handle?.created_at) {
return
}
const profile = await tryFunc(() => nip05.queryProfile(kind0.nip05), true)
if (profile?.pubkey !== e.pubkey) {
return
}
this.handles.patch({
profile: profile,
pubkey: e.pubkey,
address: kind0.nip05,
created_at: e.created_at,
updated_at: now(),
})
})
})
}
getHandle = pubkey => this.handles.get(pubkey)
displayHandle = handle =>
handle.address.startsWith("_@") ? last(handle.address.split("@")) : handle.address
}

View File

@ -1,116 +0,0 @@
import {fetchJson, now, tryFunc, tryJson, hexToBech32, bech32ToHex} from "src/util/misc"
import {invoiceAmount} from "src/util/lightning"
import {Tags} from "src/util/nostr"
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
if (address.startsWith("lnurl1")) {
return tryFunc(() => bech32ToHex(address))
}
// Try to parse it as a lud16 address
if (address.includes("@")) {
const [name, domain] = address.split("@")
if (domain && name) {
return `https://${domain}/.well-known/lnurlp/${name}`
}
}
}
export class Nip57 {
zappers: Table<Zapper>
constructor(sync: Sync) {
this.zappers = sync.table("niip57/zappers", "pubkey", {
max: 5000,
sort: sync.sortByPubkeyWhitelist,
})
sync.addHandler(0, e => {
tryJson(async () => {
const kind0 = JSON.parse(e.content)
const zapper = this.zappers.get(e.pubkey)
const address = (kind0.lud16 || kind0.lud06 || "").toLowerCase()
if (!address || e.created_at < zapper?.created_at) {
return
}
const url = getLnUrl(address)
if (!url) {
return
}
const result = await tryFunc(() => fetchJson(url), true)
if (!result?.allowsNostr || !result?.nostrPubkey) {
return
}
this.zappers.patch({
pubkey: e.pubkey,
lnurl: hexToBech32("lnurl", url),
callback: result.callback,
minSendable: result.minSendable,
maxSendable: result.maxSendable,
nostrPubkey: result.nostrPubkey,
created_at: e.created_at,
updated_at: now(),
})
})
})
}
processZaps = (zaps, pubkey) => {
const zapper = this.zappers.get(pubkey)
if (!zapper) {
return []
}
return zaps
.map(zap => {
const zapMeta = Tags.from(zap).asMeta()
return tryJson(() => ({
...zap,
invoiceAmount: invoiceAmount(zapMeta.bolt11),
request: JSON.parse(zapMeta.description),
}))
})
.filter(zap => {
if (!zap) {
return false
}
// Don't count zaps that the user sent himself
if (zap.request.pubkey === pubkey) {
return false
}
const {invoiceAmount, request} = zap
const reqMeta = Tags.from(request).asMeta()
// Verify that the zapper actually sent the requested amount (if it was supplied)
if (reqMeta.amount && parseInt(reqMeta.amount) !== invoiceAmount) {
return false
}
// If the sending client provided an lnurl tag, verify that too
if (reqMeta.lnurl && reqMeta.lnurl !== zapper.lnurl) {
return false
}
// Verify that the zap note actually came from the recipient's zapper
if (zapper.nostrPubkey !== zap.pubkey) {
return false
}
return true
})
}
}

View File

@ -1,262 +0,0 @@
import {sortBy, pluck, uniq, nth, uniqBy, prop, last, inc} from "ramda"
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 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 {
sync: Sync
relays: Table<Relay>
policies: Table<RelayPolicy>
searchRelays: Readable<(query: string) => Relay[]>
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"]}))
sync.addHandler(2, e => {
if (isShareableRelay(e.content)) {
this.addRelay(normalizeRelayUrl(e.content))
}
})
sync.addHandler(3, e => {
this.setPolicy(
e,
tryJson(() => {
Object.entries(JSON.parse(e.content || ""))
.filter(([url]) => isShareableRelay(url))
.map(([url, conditions]) => {
// @ts-ignore
const write = ![false, "!"].includes(conditions.write)
// @ts-ignore
const read = ![false, "!"].includes(conditions.read)
return {url: normalizeRelayUrl(url), write, read}
})
})
)
})
sync.addHandler(10002, e => {
this.setPolicy(
e,
Tags.from(e)
.type("r")
.all()
.map(([_, url, mode]) => {
const write = !mode || mode === "write"
const read = !mode || mode === "read"
if (!write && !read) {
warn(`Encountered unknown relay mode: ${mode}`)
}
return {url: normalizeRelayUrl(url), write, read}
})
)
})
;(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 && DUFFLEPUD_URL) {
try {
const json = await fetchJson(DUFFLEPUD_URL + "/relay")
json.relays.filter(isShareableRelay).forEach(this.addRelay)
} catch (e) {
warn("Failed to fetch relays list", e)
}
}
})()
}
addRelay = url => {
const relay = this.relays.get(url)
this.relays.patch({
url,
count: inc(relay?.count || 0),
first_seen: relay?.first_seen || now(),
info: {
last_checked: 0,
},
})
}
setPolicy = ({pubkey, created_at}, relays) => {
if (relays?.length > 0) {
if (created_at < this.policies.get(pubkey)?.created_at) {
return
}
this.policies.patch({
pubkey,
created_at,
updated_at: now(),
relays: uniqBy(prop("url"), relays).map(relay => {
this.addRelay(relay.url)
return {read: true, write: true, ...relay}
}),
})
}
}
getRelay = (url: string): Relay => this.relays.get(url) || {url}
getRelayInfo = (url: string): RelayInfo => this.relays.get(url)?.info || {}
displayRelay = ({url}) => last(url.split("://"))
getSearchRelays = () => {
const searchableRelayUrls = pluck(
"url",
this.relays.all({"info.supported_nips": {$contains: 50}})
)
return uniq(this.sync.env.SEARCH_RELAYS.concat(searchableRelayUrls)).slice(0, 8)
}
getPubkeyRelays = (pubkey, mode = null) => {
const relays = this.policies.get(pubkey)?.relays || []
return mode ? relays.filter(prop(mode)) : relays
}
getPubkeyRelayUrls = (pubkey, mode = null) => pluck("url", this.getPubkeyRelays(pubkey, mode))
// Smart relay selection
//
// From Mike Dilger:
// 1) Other people's write relays — pull events from people you follow,
// including their contact lists
// 2) Other people's read relays — push events that tag them (replies or just tagging).
// However, these may be authenticated, use with caution
// 3) Your write relays —- write events you post to your microblog feed for the
// world to see. ALSO write your contact list. ALSO read back your own contact list.
// 4) Your read relays —- read events that tag you. ALSO both write and read
// client-private data like client configuration events or anything that the world
// doesn't need to see.
// 5) Advertise relays — write and read back your own relay list
selectHints = (limit, hints) => {
const seen = new Set()
const ok = []
const bad = []
for (const url of chain(hints, this.opts.getDefaultRelays())) {
if (seen.has(url)) {
continue
}
seen.add(url)
// Filter out relays that appear to be broken or slow
if (!isShareableRelay(url)) {
bad.push(url)
} else if (this.opts.relayHasError(url)) {
bad.push(url)
} else {
ok.push(url)
}
if (ok.length > limit) {
break
}
}
// If we don't have enough hints, use the broken ones
return ok.concat(bad).slice(0, limit)
}
hintSelector =
generateHints =>
(limit, ...args) =>
this.selectHints(limit, generateHints.call(this, ...args))
getPubkeyHints = this.hintSelector(function* (pubkey, mode = "write") {
const other = mode === "write" ? "read" : "write"
yield* this.getPubkeyRelayUrls(pubkey, mode)
yield* this.getPubkeyRelayUrls(pubkey, other)
})
getEventHints = this.hintSelector(function* (event) {
yield* event.seen_on || []
yield* this.getPubkeyHints(null, event.pubkey)
})
// If we're looking for an event's children, the read relays the author has
// advertised would be the most reliable option, since well-behaved clients
// 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.getPubkeyRelayUrls(event.pubkey, "write")
yield* event.seen_on || []
yield* this.getPubkeyRelayUrls(event.pubkey, "read")
})
// If we're looking for an event's parent, tags are the most reliable hint,
// but we can also look at where the author of the note reads from
getParentHints = this.hintSelector(function* (event) {
const parentId = findReplyId(event)
yield* Tags.from(event).equals(parentId).relays()
yield* event.seen_on || []
yield* this.getPubkeyHints(null, event.pubkey, "read")
})
// If we're replying or reacting to an event, we want the author to know, as well as
// 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, extraRelays = []) => {
const tags = Tags.from(event)
const pubkeys = tags.type("p").values().all().concat(event.pubkey)
const hintGroups = pubkeys.map(pubkey => this.getPubkeyHints(3, pubkey, "read"))
return this.mergeHints(limit, hintGroups.concat([extraRelays]))
}
mergeHints = (limit, groups) => {
const scores = {} as Record<string, any>
for (const hints of groups) {
hints.forEach((hint, i) => {
const score = 1 / (i + 1) / hints.length
if (!scores[hint]) {
scores[hint] = {score: 0, count: 0}
}
scores[hint].score += score
scores[hint].count += 1
})
}
// Use the log-sum-exp and a weighted sum
for (const score of Object.values(scores)) {
const weight = Math.log(groups.length / score.count)
score.score = weight + Math.log1p(Math.exp(score.score - score.count))
}
return sortBy(([hint, {score}]) => -score, Object.entries(scores))
.map(nth(0))
.slice(0, limit)
}
}

View File

@ -1,100 +0,0 @@
import {ensurePlural} from "hurdak/lib/hurdak"
import {now} from "src/util/misc"
import {Tags} from "src/util/nostr"
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 {
sync: Sync
graph: Table<GraphEntry>
constructor(sync) {
this.sync = sync
this.graph = sync.table("social/graph", "pubkey", {
max: 5000,
sort: sync.sortByPubkeyWhitelist,
})
sync.addHandler(3, e => {
const entry = this.graph.get(e.pubkey)
if (e.created_at < entry?.petnames_updated_at) {
return
}
this.graph.patch({
pubkey: e.pubkey,
updated_at: now(),
petnames_updated_at: e.created_at,
petnames: Tags.from(e).type("p").all(),
})
})
sync.addHandler(10000, e => {
const entry = this.graph.get(e.pubkey)
if (e.created_at < entry?.mutes_updated_at) {
return
}
this.graph.patch({
pubkey: e.pubkey,
updated_at: now(),
mutes_updated_at: e.created_at,
mutes: Tags.from(e).type("p").all(),
})
})
}
getPetnames = pubkey => this.graph.get(pubkey)?.petnames || []
getMutedTags = pubkey => this.graph.get(pubkey)?.mutes || []
getFollowsSet = pubkeys => {
const follows = new Set()
for (const pubkey of ensurePlural(pubkeys)) {
for (const tag of this.getPetnames(pubkey)) {
follows.add(tag[1])
}
}
return follows
}
getMutesSet = pubkeys => {
const mutes = new Set()
for (const pubkey of ensurePlural(pubkeys)) {
for (const tag of this.getMutedTags(pubkey)) {
mutes.add(tag[1])
}
}
return mutes
}
getFollows = pubkeys => Array.from(this.getFollowsSet(pubkeys))
getMutes = pubkeys => Array.from(this.getMutesSet(pubkeys))
getNetworkSet = (pubkeys, includeFollows = false) => {
const follows = this.getFollowsSet(pubkeys)
const network = includeFollows ? follows : new Set()
for (const pubkey of this.getFollows(follows)) {
if (!follows.has(pubkey)) {
network.add(pubkey)
}
}
return network
}
getNetwork = pubkeys => Array.from(this.getNetworkSet(pubkeys))
isFollowing = (a, b) => this.getFollowsSet(a).has(b)
isIgnoring = (a, b) => this.getMutesSet(a).has(b)
}

View File

@ -1,181 +0,0 @@
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 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
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
}
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

@ -1,166 +0,0 @@
import {last, pick, uniqBy} from "ramda"
import {doPipe} from "hurdak/lib/hurdak"
import {Tags, channelAttrs, findRoot, findReply} from "src/util/nostr"
import {parseContent} from "src/util/notes"
export type BuilderOpts = {
getPubkeyPetname: (pubkey: string) => string
getPubkeyHint: (pubkey: string) => string
getEventHint: (event: Event) => string
}
const uniqTags = uniqBy(t => t.slice(0, 2).join(":"))
const buildEvent = (kind, {content = "", tags = [], tagClient = true}) => {
if (tagClient) {
tags = tags.filter(t => t[0] !== "client").concat([["client", "coracle"]])
}
return {kind, content, tags}
}
export class Builder {
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 hint = this.getPubkeyHint(pubkey)
const petname = this.getPubkeyPetname(pubkey)
return ["p", pubkey, hint, petname]
}
tagsFromContent(content, tags) {
const seen = new Set(Tags.wrap(tags).values().all())
for (const {type, value} of parseContent({content})) {
if (type === "topic") {
tags = tags.concat([["t", value]])
seen.add(value)
}
if (type.match(/nostr:(note|nevent)/) && !seen.has(value.id)) {
tags = tags.concat([["e", value.id, value.relays?.[0] || "", "mention"]])
seen.add(value.id)
}
if (type.match(/nostr:(nprofile|npub)/) && !seen.has(value.pubkey)) {
tags = tags.concat([this.mention(value.pubkey)])
seen.add(value.pubkey)
}
}
return tags
}
getReplyTags(n, inherit = false) {
const extra = inherit
? Tags.from(n)
.type("e")
.reject(t => last(t) === "mention")
.all()
.map(t => t.slice(0, 3))
: []
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),
t => t.slice(0, 3).concat("root"),
])
return [this.mention(n.pubkey), root, ...extra, reply]
}
authenticate(url, challenge) {
return buildEvent(22242, {
tags: [
["challenge", challenge],
["relay", url],
],
})
}
setProfile(profile) {
return buildEvent(0, {content: JSON.stringify(profile)})
}
setRelays(relays) {
return buildEvent(10002, {
tags: relays.map(r => {
const t = ["r", r.url]
if (!r.write) {
t.push("read")
}
return t
}),
})
}
setAppData(d, content = "") {
return buildEvent(30078, {content, tags: [["d", d]]})
}
setPetnames(petnames) {
return buildEvent(3, {tags: petnames})
}
setMutes(mutes) {
return buildEvent(10000, {tags: mutes})
}
createList(list) {
return buildEvent(30001, {tags: list})
}
createChannel(channel) {
return buildEvent(40, {content: JSON.stringify(pick(channelAttrs, channel))})
}
updateChannel({id, ...channel}) {
return buildEvent(41, {
content: JSON.stringify(pick(channelAttrs, channel)),
tags: [["e", id]],
})
}
createChatMessage(channelId, content, url) {
return buildEvent(42, {content, tags: [["e", channelId, url, "root"]]})
}
createDirectMessage(pubkey, content) {
return buildEvent(4, {content, tags: [["p", pubkey]]})
}
createNote(content, tags = []) {
return buildEvent(1, {content, tags: uniqTags(this.tagsFromContent(content, tags))})
}
createReaction(note, content) {
return buildEvent(7, {content, tags: this.getReplyTags(note)})
}
createReply(note, content, tags = []) {
return buildEvent(1, {
content,
tags: doPipe(tags, [
tags => tags.concat(this.getReplyTags(note, true)),
tags => this.tagsFromContent(content, tags),
uniqTags,
]),
})
}
requestZap(relays, content, pubkey, eventId, amount, lnurl) {
const tags = [
["relays", ...relays],
["amount", amount.toString()],
["lnurl", lnurl],
["p", pubkey],
]
if (eventId) {
tags.push(["e", eventId])
}
return buildEvent(9734, {content, tags, tagClient: false})
}
deleteEvents(ids) {
return buildEvent(5, {tags: ids.map(id => ["e", id])})
}
deleteNaddrs(naddrs) {
return buildEvent(5, {tags: naddrs.map(naddr => ["a", naddr])})
}
createLabel(payload) {
return buildEvent(1985, payload)
}
}

View File

@ -1,87 +0,0 @@
import {without, uniq} from "ramda"
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"
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[]
kinds?: number[]
force?: boolean
}
export class PubkeyLoader {
attemptedPubkeys: Set<string>
getLastUpdated: PubkeyLoaderOpts["getLastUpdated"]
getChunkRelays: PubkeyLoaderOpts["getChunkRelays"]
loadChunk: PubkeyLoaderOpts["loadChunk"]
constructor({getLastUpdated, getChunkRelays, loadChunk}: PubkeyLoaderOpts) {
this.attemptedPubkeys = new Set()
this.getLastUpdated = getLastUpdated
this.getChunkRelays = getChunkRelays
this.loadChunk = loadChunk
}
getStalePubkeys = pubkeys => {
const stale = new Set()
const since = now() - timedelta(3, "hours")
for (const pubkey of pubkeys) {
if (stale.has(pubkey) || this.attemptedPubkeys.has(pubkey)) {
continue
}
this.attemptedPubkeys.add(pubkey)
if (this.getLastUpdated(pubkey) > since) {
continue
}
stale.add(pubkey)
}
return stale
}
loadPubkeys = async (rawPubkeys, {relays, force, kinds = personKinds}: LoadPeopleOpts = {}) => {
const pubkeys = force ? uniq(rawPubkeys) : this.getStalePubkeys(rawPubkeys)
const getChunkRelays = chunk => {
if (relays?.length > 0) {
return relays
}
return this.getChunkRelays(chunk)
}
const getChunkFilter = chunk => {
const filter = [] as Filter[]
filter.push({kinds: without([30078], kinds), authors: chunk})
// Add a separate filter for app data so we're not pulling down other people's stuff,
// or obsolete events of our own.
if (kinds.includes(30078)) {
filter.push({kinds: [30078], authors: chunk, "#d": appDataKeys})
}
return filter
}
await Promise.all(
chunk(256, pubkeys).map(async chunk => {
await this.loadChunk({
relays: getChunkRelays(chunk),
filter: getChunkFilter(chunk),
})
})
)
}
}