Move the rest over to engine

This commit is contained in:
Jonathan Staab 2023-07-12 13:45:47 -07:00
parent d5630e7ab7
commit 8de6980802
12 changed files with 1198 additions and 167 deletions

View File

@ -0,0 +1,178 @@
import {last, pick, uniqBy} from "ramda"
import {doPipe, first} from "hurdak/lib/hurdak"
import {Tags, channelAttrs, findRoot, findReply} from "src/util/nostr"
import {parseContent} from "src/util/notes"
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 function contributeActions({Routing, Directory}) {
const getEventHint = event => first(Routing.getEventHints(1, event)) || ""
const getPubkeyHint = pubkey => first(Routing.getPubkeyHints(1, pubkey)) || ""
const getPubkeyPetname = pubkey => {
const profile = Directory.getProfile(pubkey)
return profile ? Directory.displayProfile(profile) : ""
}
const mention = pubkey => {
const hint = getPubkeyHint(pubkey)
const petname = getPubkeyPetname(pubkey)
return ["p", pubkey, hint, petname]
}
const 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([mention(value.pubkey)])
seen.add(value.pubkey)
}
}
return tags
}
const 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 = 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 [mention(n.pubkey), root, ...extra, reply]
}
const authenticate = (url, challenge) =>
buildEvent(22242, {
tags: [
["challenge", challenge],
["relay", url],
],
})
const setProfile = profile => buildEvent(0, {content: JSON.stringify(profile)})
const setRelays = relays =>
buildEvent(10002, {
tags: relays.map(r => {
const t = ["r", r.url]
if (!r.write) {
t.push("read")
}
return t
}),
})
const setAppData = (d, content = "") => buildEvent(30078, {content, tags: [["d", d]]})
const setPetnames = petnames => buildEvent(3, {tags: petnames})
const setMutes = mutes => buildEvent(10000, {tags: mutes})
const createList = list => buildEvent(30001, {tags: list})
const createChannel = channel =>
buildEvent(40, {content: JSON.stringify(pick(channelAttrs, channel))})
const updateChannel = ({id, ...channel}) =>
buildEvent(41, {
content: JSON.stringify(pick(channelAttrs, channel)),
tags: [["e", id]],
})
const createChatMessage = (channelId, content, url) =>
buildEvent(42, {content, tags: [["e", channelId, url, "root"]]})
const createDirectMessage = (pubkey, content) => buildEvent(4, {content, tags: [["p", pubkey]]})
const createNote = (content, tags = []) =>
buildEvent(1, {content, tags: uniqTags(tagsFromContent(content, tags))})
const createReaction = (note, content) => buildEvent(7, {content, tags: getReplyTags(note)})
const createReply = (note, content, tags = []) =>
buildEvent(1, {
content,
tags: doPipe(tags, [
tags => tags.concat(getReplyTags(note, true)),
tags => tagsFromContent(content, tags),
uniqTags,
]),
})
const 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})
}
const deleteEvents = ids => buildEvent(5, {tags: ids.map(id => ["e", id])})
const deleteNaddrs = naddrs => buildEvent(5, {tags: naddrs.map(naddr => ["a", naddr])})
const createLabel = payload => buildEvent(1985, payload)
return {
tagsFromContent,
getReplyTags,
authenticate,
setProfile,
setRelays,
setAppData,
setPetnames,
setMutes,
createList,
createChannel,
updateChannel,
createChatMessage,
createDirectMessage,
createNote,
createReaction,
createReply,
requestZap,
deleteEvents,
deleteNaddrs,
createLabel,
}
}

View File

@ -1,58 +1,22 @@
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 {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" import type {Profile} from "src/system/types"
import {collection, derived} from "../util/store"
export class Directory { export function contributeState() {
profiles: Table<Profile> const profiles = collection<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 {profiles}
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 => { export function contributeSelectors({Directory}) {
tryJson(() => { const getProfile = (pubkey: string): Profile => Directory.profiles.getKey(pubkey) || {pubkey}
const kind0 = JSON.parse(e.content)
const profile = this.profiles.get(e.pubkey)
if (e.created_at < profile?.created_at) { const getNamedProfiles = () =>
return Directory.profiles.all().filter(p => p.name || p.nip05 || p.display_name)
}
this.profiles.patch({ const displayProfile = ({display_name, name, pubkey}: Profile) => {
...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) { if (display_name) {
return ellipsize(display_name, 60) return ellipsize(display_name, 60)
} }
@ -70,5 +34,34 @@ export class Directory {
} }
} }
displayPubkey = pubkey => this.displayProfile(this.getProfile(pubkey)) const displayPubkey = pubkey => displayProfile(getProfile(pubkey))
const searchProfiles = derived(Directory.profiles, $profiles => {
return fuzzy(getNamedProfiles(), {
keys: ["name", "display_name", {name: "nip05", weight: 0.5}, {name: "about", weight: 0.1}],
threshold: 0.3,
})
})
return {getProfile, getNamedProfiles, displayProfile, displayPubkey, searchProfiles}
}
export function initialize({Events, Directory}) {
Events.addHandler(0, e => {
tryJson(() => {
const kind0 = JSON.parse(e.content)
const profile = Directory.profiles.getKey(e.pubkey)
if (e.created_at < profile?.created_at) {
return
}
Directory.profiles.mergeKey(e.pubkey, {
...kind0,
pubkey: e.pubkey,
created_at: e.created_at,
updated_at: now(),
})
})
})
} }

View File

@ -0,0 +1,137 @@
import {Socket} from "paravel"
import {now} from "src/util/misc"
import {switcher} from "hurdak/lib/hurdak"
import type {RelayStat} from "src/system/types"
import {collection} from "../util/store"
export function contributeState() {
const relayStats = collection<RelayStat>()
return {relayStats}
}
export function contributeActions({Meta, Network}) {
const getRelayStats = url => Meta.relayStats.getKey(url)
const getRelayQuality = url => {
const stats = 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 (Network.pool.get(url).status === Socket.STATUS.READY) {
return [1, "Connected"]
}
return [0.5, "Not Connected"]
}
return {getRelayStats, getRelayQuality}
}
export function initialize({Network, Meta}) {
Network.pool.on("open", ({url}) => {
Meta.relayStats.mergeKey(url, {last_opened: now(), last_activity: now()})
})
Network.pool.on("close", ({url}) => {
Meta.relayStats.mergeKey(url, {last_closed: now(), last_activity: now()})
})
Network.pool.on("error:set", (url, error) => {
Meta.relayStats.mergeKey(url, {error})
})
Network.pool.on("error:clear", url => {
Meta.relayStats.mergeKey(url, {error: null})
})
Network.emitter.on("publish", urls => {
for (const url of urls) {
Meta.relayStats.mergeKey(url, {
last_publish: now(),
last_activity: now(),
})
}
})
Network.emitter.on("sub:open", urls => {
for (const url of urls) {
const stats = Meta.getRelayStats(url)
Meta.relayStats.mergeKey(url, {
last_sub: now(),
last_activity: now(),
total_subs: (stats?.total_subs || 0) + 1,
active_subs: (stats?.active_subs || 0) + 1,
})
}
})
Network.emitter.on("sub:close", urls => {
for (const url of urls) {
const stats = Meta.getRelayStats(url)
Meta.relayStats.mergeKey(url, {
last_activity: now(),
active_subs: stats ? stats.active_subs - 1 : 0,
})
}
})
Network.emitter.on("event", ({url}) => {
const stats = Meta.getRelayStats(url)
Meta.relayStats.mergeKey(url, {
last_activity: now(),
events_count: (stats.events_count || 0) + 1,
})
})
Network.emitter.on("eose", (url, ms) => {
const stats = Meta.getRelayStats(url)
Meta.relayStats.mergeKey(url, {
last_activity: now(),
eose_count: (stats.eose_count || 0) + 1,
eose_timer: (stats.eose_timer || 0) + ms,
})
})
Network.emitter.on("timeout", (url, ms) => {
const stats = Meta.getRelayStats(url)
Meta.relayStats.mergeKey(url, {
last_activity: now(),
timeouts: (stats.timeouts || 0) + 1,
})
})
}

View File

@ -0,0 +1,332 @@
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} from "src/system/types"
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 function contributeState() {
const authHandler = null
const emitter = new EventEmitter()
const pool = new Pool()
return {authHandler, emitter, pool}
}
export function contributeActions({Network, User, Events, Routing, Env}) {
const getExecutor = (urls, {bypassBoot = false} = {}) => {
if (Env.FORCE_RELAYS?.length > 0) {
urls = Env.FORCE_RELAYS
}
let target
const muxUrl = User.getSetting("multiplextr_url")
// Try to use our multiplexer, but if it fails to connect fall back to relays. If
// we're only connecting to a single relay, just do it directly, unless we already
// have a connection to the multiplexer open, in which case we're probably doing
// AUTH with a single relay.
if (muxUrl && (urls.length > 1 || Network.pool.has(muxUrl))) {
const socket = Network.pool.get(muxUrl)
if (!socket.error) {
target = new Plex(urls, socket)
}
}
if (!target) {
target = new Relays(urls.map(url => Network.pool.get(url)))
}
const executor = new Executor(target)
executor.handleAuth({
onAuth(url, challenge) {
Network.emitter.emit("error:set", url, "unauthorized")
return Network.authHandler?.(url, challenge)
},
onOk(url, id, ok, message) {
Network.emitter.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) {
Network.pool.get(url)
Network.pool.booted = true
}
},
})
// Eagerly connect and handle AUTH
executor.target.sockets.forEach(socket => {
const {limitation} = Routing.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
}
const publish = ({relays, event, onProgress, timeout = 3000, verb = "EVENT"}) => {
const urls = getUrls(relays)
const executor = getExecutor(urls, {bypassBoot: verb === "AUTH"})
Network.emitter.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()
})
}
const subscribe = ({relays, filter, onEvent, onEose, shouldProcess = true}: SubscribeOpts) => {
const urls = getUrls(relays)
const executor = 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})
Network.emitter.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
}
Network.emitter.emit("event", {url, event})
if (shouldProcess) {
Events.queue.push(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)) {
Network.emitter.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()
Network.emitter.emit("sub:close", urls)
closed = true
}
}
const 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)) {
Network.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 = subscribe({
relays,
filter,
shouldProcess,
onEvent: event => {
onEvent?.(event)
allEvents.push(event)
},
onEose: url => {
eose.add(url)
attemptToComplete(false)
},
})
}) as Promise<Event[]>
}
const count = async filter => {
const filters = ensurePlural(filter)
const executor = getExecutor(Env.COUNT_RELAYS)
return new Promise(resolve => {
const sub = executor.count(filters, {
onCount: (url, {count}) => resolve(count),
})
setTimeout(() => {
resolve(0)
sub.unsubscribe()
executor.target.cleanup()
}, 3000)
})
}
return {subscribe, publish, load, count}
}

View File

@ -1,9 +1,8 @@
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 {Table} from "src/util/loki"
import type {Sync} from "src/system/components/Sync"
import type {Zapper} from "src/system/types" import type {Zapper} from "src/system/types"
import {collection} from "../util/store"
const getLnUrl = address => { const getLnUrl = address => {
// Try to parse it as a lud06 LNURL // Try to parse it as a lud06 LNURL
@ -21,52 +20,15 @@ const getLnUrl = address => {
} }
} }
export class Nip57 { export function contributeState() {
zappers: Table<Zapper> const zappers = collection<Zapper>()
constructor(sync: Sync) {
this.zappers = sync.table("niip57/zappers", "pubkey", {
max: 5000,
sort: sync.sortByPubkeyWhitelist,
})
sync.addHandler(0, e => { return {zappers}
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) { export function contributeActions({Nip57}) {
return const processZaps = (zaps, pubkey) => {
} const zapper = Nip57.zappers.getKey(pubkey)
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) { if (!zapper) {
return [] return []
@ -113,4 +75,43 @@ export class Nip57 {
return true return true
}) })
} }
return {processZaps}
}
export function initialize({Events, Nip57}) {
Events.addHandler(0, e => {
tryJson(async () => {
const kind0 = JSON.parse(e.content)
const zapper = Nip57.zappers.getKey(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
}
Nip57.zappers.setKey(e.pubkey, {
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(),
})
})
})
} }

View File

@ -0,0 +1,79 @@
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 LoadPeopleOpts = {
relays?: string[]
kinds?: number[]
force?: boolean
}
export function contributeActions({Directory, Routing, User, Network}) {
const attemptedPubkeys = new Set()
const getStalePubkeys = pubkeys => {
const stale = new Set()
const since = now() - timedelta(3, "hours")
for (const pubkey of pubkeys) {
if (stale.has(pubkey) || attemptedPubkeys.has(pubkey)) {
continue
}
attemptedPubkeys.add(pubkey)
if (Directory.profiles.getKey(pubkey)?.updated_at || 0 > since) {
continue
}
stale.add(pubkey)
}
return stale
}
const loadPubkeys = async (
rawPubkeys,
{relays, force, kinds = personKinds}: LoadPeopleOpts = {}
) => {
const pubkeys = force ? uniq(rawPubkeys) : getStalePubkeys(rawPubkeys)
const getChunkRelays = chunk => {
if (relays?.length > 0) {
return relays
}
return Routing.mergeHints(
User.getSetting("relay_limit"),
chunk.map(pubkey => Routing.getPubkeyHints(3, pubkey))
)
}
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 Network.load({
relays: getChunkRelays(chunk),
filter: getChunkFilter(chunk),
})
})
)
}
return {loadPubkeys}
}

View File

@ -1,61 +1,25 @@
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 {Table} from "src/util/loki"
import type {Sync} from "src/system/components/Sync"
import type {GraphEntry} from "src/system/types" import type {GraphEntry} from "src/system/types"
import {collection} from "../util/store"
export class Social { export function contributeState() {
sync: Sync const graph = collection<GraphEntry>()
graph: Table<GraphEntry>
constructor(sync) {
this.sync = sync
this.graph = sync.table("social/graph", "pubkey", { return {graph}
max: 5000, }
sort: sync.sortByPubkeyWhitelist,
})
sync.addHandler(3, e => { export function contributeActions({Social}) {
const entry = this.graph.get(e.pubkey) const getPetnames = pubkey => Social.graph.getKey(pubkey)?.petnames || []
if (e.created_at < entry?.petnames_updated_at) { const getMutedTags = pubkey => Social.graph.getKey(pubkey)?.mutes || []
return
}
this.graph.patch({ const getFollowsSet = pubkeys => {
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() const follows = new Set()
for (const pubkey of ensurePlural(pubkeys)) { for (const pubkey of ensurePlural(pubkeys)) {
for (const tag of this.getPetnames(pubkey)) { for (const tag of getPetnames(pubkey)) {
follows.add(tag[1]) follows.add(tag[1])
} }
} }
@ -63,11 +27,11 @@ export class Social {
return follows return follows
} }
getMutesSet = pubkeys => { const getMutesSet = pubkeys => {
const mutes = new Set() const mutes = new Set()
for (const pubkey of ensurePlural(pubkeys)) { for (const pubkey of ensurePlural(pubkeys)) {
for (const tag of this.getMutedTags(pubkey)) { for (const tag of getMutedTags(pubkey)) {
mutes.add(tag[1]) mutes.add(tag[1])
} }
} }
@ -75,15 +39,15 @@ export class Social {
return mutes return mutes
} }
getFollows = pubkeys => Array.from(this.getFollowsSet(pubkeys)) const getFollows = pubkeys => Array.from(getFollowsSet(pubkeys))
getMutes = pubkeys => Array.from(this.getMutesSet(pubkeys)) const getMutes = pubkeys => Array.from(getMutesSet(pubkeys))
getNetworkSet = (pubkeys, includeFollows = false) => { const getNetworkSet = (pubkeys, includeFollows = false) => {
const follows = this.getFollowsSet(pubkeys) const follows = getFollowsSet(pubkeys)
const network = includeFollows ? follows : new Set() const network = includeFollows ? follows : new Set()
for (const pubkey of this.getFollows(follows)) { for (const pubkey of getFollows(follows)) {
if (!follows.has(pubkey)) { if (!follows.has(pubkey)) {
network.add(pubkey) network.add(pubkey)
} }
@ -92,9 +56,54 @@ export class Social {
return network return network
} }
getNetwork = pubkeys => Array.from(this.getNetworkSet(pubkeys)) const getNetwork = pubkeys => Array.from(getNetworkSet(pubkeys))
isFollowing = (a, b) => this.getFollowsSet(a).has(b) const isFollowing = (a, b) => getFollowsSet(a).has(b)
isIgnoring = (a, b) => this.getMutesSet(a).has(b) const isIgnoring = (a, b) => getMutesSet(a).has(b)
return {
getPetnames,
getMutedTags,
getFollowsSet,
getMutesSet,
getFollows,
getMutes,
getNetworkSet,
getNetwork,
isFollowing,
isIgnoring,
}
}
export function initialize({Events, Social}) {
Events.addHandler(3, e => {
const entry = Social.graph.getKey(e.pubkey)
if (e.created_at < entry?.petnames_updated_at) {
return
}
Social.graph.mergeKey(e.pubkey, {
pubkey: e.pubkey,
updated_at: now(),
petnames_updated_at: e.created_at,
petnames: Tags.from(e).type("p").all(),
})
})
Events.addHandler(10000, e => {
const entry = Social.graph.getKey(e.pubkey)
if (e.created_at < entry?.mutes_updated_at) {
return
}
Social.graph.mergeKey(e.pubkey, {
pubkey: e.pubkey,
updated_at: now(),
mutes_updated_at: e.created_at,
mutes: Tags.from(e).type("p").all(),
})
})
} }

View File

@ -0,0 +1,288 @@
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} from "src/util/misc"
import {Tags, normalizeRelayUrl, findReplyId, findRootId} from "src/util/nostr"
import {collection} from "../util/store"
export function contributeState({Env}) {
const settings = collection<any>({
last_updated: 0,
relay_limit: 10,
default_zap: 21,
show_media: true,
report_analytics: true,
dufflepud_url: Env.DUFFLEPUD_URL,
multiplextr_url: Env.MULTIPLEXTR_URL,
})
return {settings}
}
export function contributeActions({
Keys,
Directory,
Events,
Network,
Crypt,
Builder,
Social,
Routing,
User,
Chat,
Content,
}) {
const getPubkey = () => Keys.pubkey.get()
const getProfile = () => Directory.getProfile(getPubkey())
const isUserEvent = id => Events.cache.getKey(id)?.pubkey === getPubkey()
const getStateKey = () => (Keys.canSign.get() ? getPubkey() : "anonymous")
// Publish
const prepEvent = async rawEvent => {
return doPipe(rawEvent, [
assoc("created_at", now()),
assoc("pubkey", getPubkey()),
e => ({...e, id: getEventHash(e)}),
Keys.sign,
])
}
const publish = async (event, relays = null, onProgress = null, verb = "EVENT") => {
if (!event.sig) {
event = await prepEvent(event)
}
if (!relays) {
relays = getRelayUrls("write")
}
// return console.log(event)
const promise = Network.publish({event, relays, onProgress, verb})
Events.queue.push(event)
return [event, promise]
}
// Settings
const getSetting = k => User.settings.getKey(k)
const dufflepud = path => `${getSetting("dufflepud_url")}/${path}`
const setSettings = async settings => {
User.settings.update($settings => ({...$settings, ...settings}))
if (Keys.canSign.get()) {
const d = "coracle/settings/v1"
const v = await Crypt.encryptJson(settings)
return publish(Builder.setAppData(d, v))
}
}
const setAppData = async (key, content) => {
if (Keys.canSign.get()) {
const d = `coracle/${key}`
const v = await Crypt.encryptJson(content)
return publish(Builder.setAppData(d, v))
}
}
// Routing
const getRelays = (mode?: string) => Routing.getPubkeyRelays(getStateKey(), mode)
const getRelayUrls = (mode?: string) => Routing.getPubkeyRelayUrls(getStateKey(), mode)
const setRelays = relays => {
if (Keys.canSign.get()) {
return publish(Builder.setRelays(relays))
} else {
Routing.setPolicy({pubkey: getStateKey(), created_at: now()}, relays)
}
}
const addRelay = url => setRelays(getRelays().concat({url, read: true, write: true}))
const removeRelay = url => setRelays(reject(whereEq({url: normalizeRelayUrl(url)}), getRelays()))
const setRelayPolicy = (url, policy) =>
setRelays(getRelays().map(when(whereEq({url}), p => ({...p, ...policy}))))
// Social
const getPetnames = () => Social.getPetnames(getStateKey())
const getMutedTags = () => Social.getMutedTags(getStateKey())
const getFollowsSet = () => Social.getFollowsSet(getStateKey())
const getMutesSet = () => Social.getMutesSet(getStateKey())
const getFollows = () => Social.getFollows(getStateKey())
const getMutes = () => Social.getMutes(getStateKey())
const getNetworkSet = () => Social.getNetworkSet(getStateKey())
const getNetwork = () => Social.getNetwork(getStateKey())
const isFollowing = pubkey => Social.isFollowing(getStateKey(), pubkey)
const isIgnoring = pubkeyOrEventId => Social.isIgnoring(getStateKey(), pubkeyOrEventId)
const setProfile = $profile => publish(Builder.setProfile($profile))
const setPetnames = async $petnames => {
if (Keys.canSign.get()) {
await publish(Builder.setPetnames($petnames))
} else {
Social.graph.mergeKey(getStateKey(), {
pubkey: getStateKey(),
updated_at: now(),
petnames_updated_at: now(),
petnames: $petnames,
})
}
}
const follow = pubkey =>
setPetnames(
getPetnames()
.filter(t => t[1] !== pubkey)
.concat([Builder.mention(pubkey)])
)
const unfollow = pubkey => setPetnames(reject(t => t[1] === pubkey, getPetnames()))
const isMuted = e => {
const m = getMutesSet()
return find(t => m.has(t), [e.id, e.pubkey, findReplyId(e), findRootId(e)])
}
const applyMutes = events => reject(isMuted, events)
const setMutes = async $mutes => {
if (Keys.canSign.get()) {
await publish(Builder.setMutes($mutes.map(slice(0, 2))))
} else {
Social.graph.mergeKey(getStateKey(), {
pubkey: getStateKey(),
updated_at: now(),
mutes_updated_at: now(),
mutes: $mutes,
})
}
}
const mute = (type, value) =>
setMutes(reject(t => t[1] === value, getMutes()).concat([[type, value]]))
const unmute = target => setMutes(reject(t => t[1] === target, getMutes()))
// Content
const getLists = (spec = null) => Content.getLists({...spec, pubkey: getStateKey()})
const putList = (name, params, relays) =>
publish(Builder.createList([["d", name]].concat(params).concat(relays)))
const removeList = naddr => publish(Builder.deleteNaddrs([naddr]))
// Chat
const setLastChecked = (channelId, timestamp) => {
const lastChecked = fromPairs(
Chat.channels.all({last_checked: {$type: "number"}}).map(r => [r.id, r.last_checked])
)
return setAppData("last_checked/v1", {...lastChecked, [channelId]: timestamp})
}
const joinChannel = channelId => {
const channelIds = uniq(pluck("id", Chat.channels.all({joined: true})).concat(channelId))
return setAppData("rooms_joined/v1", channelIds)
}
const leaveChannel = channelId => {
const channelIds = without([channelId], pluck("id", Chat.channels.all({joined: true})))
return setAppData("rooms_joined/v1", channelIds)
}
return {
getPubkey,
getProfile,
isUserEvent,
getStateKey,
getSetting,
dufflepud,
setSettings,
getRelays,
getRelayUrls,
setRelays,
addRelay,
removeRelay,
setRelayPolicy,
getPetnames,
getMutedTags,
getFollowsSet,
getMutesSet,
getFollows,
getMutes,
getNetworkSet,
getNetwork,
isFollowing,
isIgnoring,
setProfile,
setPetnames,
follow,
unfollow,
isMuted,
applyMutes,
setMutes,
mute,
unmute,
getLists,
putList,
removeList,
setLastChecked,
joinChannel,
leaveChannel,
}
}
export function initialize({Events, Crypt, User}) {
Events.addHandler(30078, async e => {
if (
Tags.from(e).getMeta("d") === "coracle/settings/v1" &&
e.created_at > User.settings.getKey("last_updated")
) {
const updates = await Crypt.decryptJson(e.content)
if (updates) {
User.settings.update($settings => ({
...$settings,
...updates,
last_updated: e.created_at,
}))
}
}
})
}

View File

@ -1,15 +1,25 @@
import * as Alerts from "./components/Alerts" import * as Alerts from "./components/Alerts"
import * as Builder from "./components/Builder"
import * as Chat from "./components/Chat" import * as Chat from "./components/Chat"
import * as Content from "./components/Content" import * as Content from "./components/Content"
import * as Crypt from "./components/Crypt" import * as Crypt from "./components/Crypt"
import * as Directory from "./components/Directory"
import * as Events from "./components/Events" import * as Events from "./components/Events"
import * as Keys from "./components/Keys" import * as Keys from "./components/Keys"
import * as Meta from "./components/Meta"
import * as Network from "./components/Network"
import * as Nip05 from "./components/Nip05" import * as Nip05 from "./components/Nip05"
import * as Nip57 from "./components/Nip57"
import * as PubkeyLoader from "./components/PubkeyLoader"
import * as Routing from "./components/Routing" import * as Routing from "./components/Routing"
import * as Social from "./components/Social"
import * as User from "./components/User"
export const createEngine = (engine, components) => { export const createEngine = (engine, components) => {
for (const component of components) { const componentState = components.map(c => [c, c.contributeState?.(engine)])
engine[component.name] = component.contributeState?.()
for (const [component, state] of componentState) {
Object.assign(engine[component.name], state)
} }
const componentSelectors = components.map(c => [c, c.contributeSelectors?.(engine)]) const componentSelectors = components.map(c => [c, c.contributeSelectors?.(engine)])
@ -36,16 +46,21 @@ export const createDefaultEngine = Env => {
{Env}, {Env},
{ {
Alerts, Alerts,
Builder,
Chat, Chat,
Content, Content,
Crypt, Crypt,
// Directory, Directory,
Events, Events,
Keys, Keys,
Meta,
Network,
Nip05, Nip05,
// Nip57, Nip57,
PubkeyLoader,
Routing, Routing,
// Social, Social,
User,
} }
) )
} }

View File

@ -127,8 +127,8 @@ export const key = <T>(baseStore, k): Key<T> => {
return keyStore return keyStore
} }
export const collection = <T>(): Collection<T> => { export const collection = <T>(defaults = {}): Collection<T> => {
const baseStore = writable<Map<any, T>>(new Map()) const baseStore = writable<Map<any, T>>(new Map(Object.entries(defaults)))
return { return {
...baseStore, ...baseStore,

View File

@ -4,10 +4,19 @@ import {doPipe} from "hurdak/lib/hurdak"
import {now, getter} from "src/util/misc" import {now, getter} from "src/util/misc"
import {Tags, normalizeRelayUrl, findReplyId, findRootId} from "src/util/nostr" import {Tags, normalizeRelayUrl, findReplyId, findRootId} from "src/util/nostr"
import type {System} from "src/system/System" import type {System} from "src/system/System"
import type {UserSettings} from "src/system/types"
import type {Writable} from "svelte/store" import type {Writable} from "svelte/store"
import engine from "src/app/system" 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 { export class User {
keys: typeof engine.keys keys: typeof engine.keys
crypt: typeof engine.crypt crypt: typeof engine.crypt

View File

@ -95,16 +95,6 @@ export type Profile = {
display_name?: string display_name?: string
} }
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 type Channel = { export type Channel = {
id: string id: string
type: "public" | "private" type: "public" | "private"