mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-29 16:31:04 +00:00
Delete system
This commit is contained in:
parent
8de6980802
commit
1f3fee870a
@ -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>()
|
||||||
|
@ -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())
|
||||||
|
@ -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() {
|
||||||
|
@ -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() {
|
||||||
|
@ -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() {
|
||||||
|
@ -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[]
|
||||||
|
@ -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() {
|
||||||
|
@ -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 => {
|
||||||
|
@ -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[]
|
||||||
|
@ -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() {
|
||||||
|
@ -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() {
|
||||||
|
@ -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"
|
||||||
|
@ -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"]
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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"
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -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}})
|
|
||||||
}
|
|
@ -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))
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
@ -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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
@ -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}),
|
|
||||||
})
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user