Separate people and profile storage

This commit is contained in:
Jonathan Staab 2023-03-14 14:28:39 -05:00
parent bd2bceaecc
commit 1adc003a98
44 changed files with 752 additions and 765 deletions

View File

@ -1,20 +1,15 @@
# Current # Current
- [ ] Fix re-connects
- [ ] Fix memory usage - [ ] Fix memory usage
- Re-write database - [x] Add table of user events, derive profile from this using `watch`.
- Use LRU cache and persist that instead. Use purgeStale/dump/load - [ ] Remove petnames from users, retrieve lazily. Then, increase people table size
- Split state persistence elsewhere - [ ] Make zapper info more compact
- Keep it focused to abstract interface, split actual tables out elsewhere - [ ] Move settings storage to an encrypted event https://github.com/nostr-protocol/nips/blob/master/78.md
- Put all other state in same place - [ ] Migrate
- Re-write to use arrays with an index of id to index - [ ] Test
- Fix compatibility, or clear data on first load of new version - [ ] Test that relays/follows made as anon don't stomp user settings on login
- Add table of user events, derive profile from this using `watch`. - [ ] Test anonymous usage, public key only usage
- Refine sync, set up some kind of system where we register tables with events coming in - [ ] Fix re-connects
- People.petnames is massive. Split people caches up
- Display/picture/about
- Minimal zapper info
- Drop petnames
- [ ] Show loading/success on zap invoice screen - [ ] Show loading/success on zap invoice screen
- [ ] Fix iOS/safari/firefox - [ ] Fix iOS/safari/firefox
- [ ] Update https://nostr.com/clients/coracle - [ ] Update https://nostr.com/clients/coracle

View File

@ -9,9 +9,9 @@
"preview": "vite preview", "preview": "vite preview",
"check:es": "eslint src/*/** --quiet", "check:es": "eslint src/*/** --quiet",
"check:ts": "svelte-check --tsconfig ./tsconfig.json --threshold error", "check:ts": "svelte-check --tsconfig ./tsconfig.json --threshold error",
"check:fmt": "prettier --check $(git diff --name-only --diff-filter d | grep -E 'js|ts|svelte$' | xargs)", "check:fmt": "prettier --check $(git diff head --name-only --diff-filter d | grep -E 'js|ts|svelte$' | xargs)",
"check": "run-p check:*", "check": "run-p check:*",
"format": "prettier --write $(git diff --name-only --diff-filter d | grep -E 'js|ts|svelte$' | xargs)", "format": "prettier --write $(git diff head --name-only --diff-filter d | grep -E 'js|ts|svelte$' | xargs)",
"watch": "find src -type f | entr -r" "watch": "find src -type f | entr -r"
}, },
"devDependencies": { "devDependencies": {

View File

@ -14,12 +14,13 @@
import {timedelta, shuffle, now, sleep} from "src/util/misc" import {timedelta, shuffle, now, sleep} from "src/util/misc"
import {displayPerson, isLike} from "src/util/nostr" import {displayPerson, isLike} from "src/util/nostr"
import cmd from "src/agent/cmd" import cmd from "src/agent/cmd"
import {ready, onReady, relays} from "src/agent/state" import {ready, onReady, relays} from "src/agent/tables"
import keys from "src/agent/keys" import keys from "src/agent/keys"
import network from "src/agent/network" import network from "src/agent/network"
import pool from "src/agent/pool" import pool from "src/agent/pool"
import {getUserRelays, initializeRelayList} from "src/agent/relays" import {getUserRelays, initializeRelayList} from "src/agent/relays"
import sync from "src/agent/sync" import sync from "src/agent/sync"
import * as tables from "src/agent/tables"
import user from "src/agent/user" import user from "src/agent/user"
import {loadAppData} from "src/app" import {loadAppData} from "src/app"
import alerts from "src/app/alerts" import alerts from "src/app/alerts"
@ -64,7 +65,7 @@
import AddRelay from "src/views/relays/AddRelay.svelte" import AddRelay from "src/views/relays/AddRelay.svelte"
import RelayCard from "src/views/relays/RelayCard.svelte" import RelayCard from "src/views/relays/RelayCard.svelte"
Object.assign(window, {cmd, user, keys, network, pool, sync}) Object.assign(window, {cmd, user, keys, network, pool, sync, tables})
export let url = "" export let url = ""

View File

@ -1,14 +1,13 @@
import {pick, last, prop, uniqBy} from 'ramda' import {pick, last, prop, uniqBy} from "ramda"
import {get} from 'svelte/store' import {get} from "svelte/store"
import {roomAttrs, displayPerson, findReplyId, findRootId} from 'src/util/nostr' import {roomAttrs, displayPerson, findReplyId, findRootId} from "src/util/nostr"
import {getPubkeyWriteRelays, getRelayForPersonHint, sampleRelays} from 'src/agent/relays' import {getPubkeyWriteRelays, getRelayForPersonHint, sampleRelays} from "src/agent/relays"
import {getPersonWithFallback} from 'src/agent/state' import {getPersonWithFallback} from "src/agent/tables"
import pool from 'src/agent/pool' import pool from "src/agent/pool"
import sync from 'src/agent/sync' import sync from "src/agent/sync"
import keys from 'src/agent/keys' import keys from "src/agent/keys"
const updateUser = updates => const updateUser = updates => new PublishableEvent(0, {content: JSON.stringify(updates)})
new PublishableEvent(0, {content: JSON.stringify(updates)})
const setRelays = newRelays => const setRelays = newRelays =>
new PublishableEvent(10002, { new PublishableEvent(10002, {
@ -16,18 +15,16 @@ const setRelays = newRelays =>
const t = ["r", r.url] const t = ["r", r.url]
if (!r.write) { if (!r.write) {
t.push('read') t.push("read")
} }
return t return t
}), }),
}) })
const setPetnames = petnames => const setPetnames = petnames => new PublishableEvent(3, {tags: petnames})
new PublishableEvent(3, {tags: petnames})
const setMutes = mutes => const setMutes = mutes => new PublishableEvent(10000, {tags: mutes})
new PublishableEvent(10000, {tags: mutes})
const createRoom = room => const createRoom = room =>
new PublishableEvent(40, {content: JSON.stringify(pick(roomAttrs, room))}) new PublishableEvent(40, {content: JSON.stringify(pick(roomAttrs, room))})
@ -58,7 +55,11 @@ const getReplyTags = n => {
const {url} = getRelayForPersonHint(n.pubkey, n) const {url} = getRelayForPersonHint(n.pubkey, n)
const rootId = findRootId(n) || findReplyId(n) || n.id const rootId = findRootId(n) || findReplyId(n) || n.id
return [["p", n.pubkey, url], ["e", n.id, url, 'reply'], ["e", rootId, url, 'root']] return [
["p", n.pubkey, url],
["e", n.id, url, "reply"],
["e", rootId, url, "root"],
]
} }
const tagsFromParent = (n, newTags = []) => { const tagsFromParent = (n, newTags = []) => {
@ -66,21 +67,20 @@ const tagsFromParent = (n, newTags = []) => {
return uniqBy( return uniqBy(
// Remove duplicates due to inheritance. Keep earlier ones // Remove duplicates due to inheritance. Keep earlier ones
t => t.slice(0, 2).join(':'), t => t.slice(0, 2).join(":"),
// Mentions have to come first for interpolation to work // Mentions have to come first for interpolation to work
newTags newTags
// Add standard reply tags // Add standard reply tags
.concat(getReplyTags(n)) .concat(getReplyTags(n))
// Inherit p and e tags, but remove marks and self-mentions // Inherit p and e tags, but remove marks and self-mentions
.concat( .concat(
n.tags n.tags.filter(t => {
.filter(t => { if (t[1] === pubkey) return false
if (t[1] === pubkey) return false if (!["p", "e"].includes(t[0])) return false
if (!["p", "e"].includes(t[0])) return false if (["reply", "root"].includes(last(t))) return false
if (['reply', 'root'].includes(last(t))) return false
return true return true
}) })
) )
) )
} }
@ -93,7 +93,7 @@ const createReply = (note, content, mentions = [], topics = []) => {
const tags = tagsFromParent( const tags = tagsFromParent(
note, note,
mentions mentions
.map(pk => ["p", pk, prop('url', getRelayForPersonHint(pk, note))]) .map(pk => ["p", pk, prop("url", getRelayForPersonHint(pk, note))])
.concat(topics.map(t => ["t", t])) .concat(topics.map(t => ["t", t]))
) )
@ -115,14 +115,13 @@ const requestZap = (relays, content, pubkey, eventId, amount, lnurl) => {
return new PublishableEvent(9734, {content, tags}) return new PublishableEvent(9734, {content, tags})
} }
const deleteEvent = ids => const deleteEvent = ids => new PublishableEvent(5, {tags: ids.map(id => ["e", id])})
new PublishableEvent(5, {tags: ids.map(id => ["e", id])})
// Utils // Utils
class PublishableEvent { class PublishableEvent {
event: Record<string, any> event: Record<string, any>
constructor(kind, {content = '', tags = []}) { constructor(kind, {content = "", tags = []}) {
const pubkey = get(keys.pubkey) const pubkey = get(keys.pubkey)
const createdAt = Math.round(new Date().valueOf() / 1000) const createdAt = Math.round(new Date().valueOf() / 1000)
@ -139,7 +138,18 @@ class PublishableEvent {
} }
export default { export default {
updateUser, setRelays, setPetnames, setMutes, createRoom, updateRoom, updateUser,
createChatMessage, createDirectMessage, createNote, createReaction, setRelays,
createReply, requestZap, deleteEvent, PublishableEvent, setPetnames,
setMutes,
createRoom,
updateRoom,
createChatMessage,
createDirectMessage,
createNote,
createReaction,
createReply,
requestZap,
deleteEvent,
PublishableEvent,
} }

View File

@ -1,23 +1,26 @@
import type {MyEvent} from 'src/util/types' import type {MyEvent} from "src/util/types"
import {sortBy, assoc, uniq, uniqBy, prop, propEq, reject, groupBy, pluck} from 'ramda' import {sortBy, assoc, uniq, uniqBy, prop, propEq, reject, groupBy, pluck} from "ramda"
import {personKinds, findReplyId} from 'src/util/nostr' import {personKinds, findReplyId} from "src/util/nostr"
import {log} from 'src/util/logger' import {log} from "src/util/logger"
import {chunk} from 'hurdak/lib/hurdak' import {chunk} from "hurdak/lib/hurdak"
import {batch, now, timedelta} from 'src/util/misc' import {batch, now, timedelta} from "src/util/misc"
import { import {
getRelaysForEventParent, getAllPubkeyWriteRelays, aggregateScores, getRelaysForEventParent,
getRelaysForEventChildren, sampleRelays, getAllPubkeyWriteRelays,
} from 'src/agent/relays' aggregateScores,
import {people} from 'src/agent/state' getRelaysForEventChildren,
import pool from 'src/agent/pool' sampleRelays,
import sync from 'src/agent/sync' } from "src/agent/relays"
import {people} from "src/agent/tables"
import pool from "src/agent/pool"
import sync from "src/agent/sync"
const getStalePubkeys = pubkeys => { const getStalePubkeys = pubkeys => {
// If we're not reloading, only get pubkeys we don't already know about // If we're not reloading, only get pubkeys we don't already know about
return uniq(pubkeys).filter(pubkey => { return uniq(pubkeys).filter(pubkey => {
const p = people.get(pubkey) const p = people.get(pubkey)
return !p || p.updated_at < now() - timedelta(1, 'days') return !p || p.updated_at < now() - timedelta(1, "days")
}) })
} }
@ -145,8 +148,8 @@ const streamContext = ({notes, onChunk, depth = 0}) =>
// Instead of recurring to depth, trampoline so we can batch requests // Instead of recurring to depth, trampoline so we can batch requests
while (events.length > 0 && depth > 0) { while (events.length > 0 && depth > 0) {
const chunk = events.splice(0) const chunk = events.splice(0)
const authors = getStalePubkeys(pluck('pubkey', chunk)) const authors = getStalePubkeys(pluck("pubkey", chunk))
const filter = [{kinds: [1, 7, 9735], '#e': pluck('id', chunk)}] as Array<object> const filter = [{kinds: [1, 7, 9735], "#e": pluck("id", chunk)}] as Array<object>
const relays = sampleRelays(aggregateScores(chunk.map(getRelaysForEventChildren))) const relays = sampleRelays(aggregateScores(chunk.map(getRelaysForEventChildren)))
// Load authors and reactions in one subscription // Load authors and reactions in one subscription
@ -169,11 +172,11 @@ const streamContext = ({notes, onChunk, depth = 0}) =>
) )
const applyContext = (notes, context) => { const applyContext = (notes, context) => {
context = context.map(assoc('isContext', true)) context = context.map(assoc("isContext", true))
const replies = context.filter(propEq('kind', 1)) const replies = context.filter(propEq("kind", 1))
const reactions = context.filter(propEq('kind', 7)) const reactions = context.filter(propEq("kind", 7))
const zaps = context.filter(propEq('kind', 9735)) const zaps = context.filter(propEq("kind", 9735))
const repliesByParentId = groupBy(findReplyId, replies) const repliesByParentId = groupBy(findReplyId, replies)
const reactionsByParentId = groupBy(findReplyId, reactions) const reactionsByParentId = groupBy(findReplyId, reactions)
@ -186,9 +189,9 @@ const applyContext = (notes, context) => {
return { return {
...note, ...note,
replies: sortBy(e => -e.created_at, uniqBy(prop('id'), combinedReplies).map(annotate)), replies: sortBy(e => -e.created_at, uniqBy(prop("id"), combinedReplies).map(annotate)),
reactions: uniqBy(prop('id'), combinedReactions), reactions: uniqBy(prop("id"), combinedReactions),
zaps: uniqBy(prop('id'), combinedZaps), zaps: uniqBy(prop("id"), combinedZaps),
} }
} }
@ -196,5 +199,10 @@ const applyContext = (notes, context) => {
} }
export default { export default {
listen, load, loadPeople, personKinds, loadParents, streamContext, applyContext, listen,
load,
loadPeople,
loadParents,
streamContext,
applyContext,
} }

View File

@ -1,13 +1,13 @@
import type {Relay} from 'src/util/types' import type {Relay} from "src/util/types"
import LRUCache from 'lru-cache' import LRUCache from "lru-cache"
import {warn} from 'src/util/logger' import {warn} from "src/util/logger"
import {filter, pipe, pick, groupBy, objOf, map, assoc, sortBy, uniqBy, prop} from 'ramda' import {filter, pipe, pick, groupBy, objOf, map, assoc, sortBy, uniqBy, prop} from "ramda"
import {first} from 'hurdak/lib/hurdak' import {first} from "hurdak/lib/hurdak"
import {Tags, isRelay, findReplyId} from 'src/util/nostr' import {Tags, isRelay, findReplyId} from "src/util/nostr"
import {shuffle, fetchJson} from 'src/util/misc' import {shuffle, fetchJson} from "src/util/misc"
import {relays, routes} from 'src/agent/state' import {relays, routes} from "src/agent/tables"
import pool from 'src/agent/pool' import pool from "src/agent/pool"
import user from 'src/agent/user' import user from "src/agent/user"
// From Mike Dilger: // From Mike Dilger:
// 1) Other people's write relays — pull events from people you follow, // 1) Other people's write relays — pull events from people you follow,
@ -26,21 +26,21 @@ import user from 'src/agent/user'
export const initializeRelayList = async () => { export const initializeRelayList = async () => {
// Throw some hardcoded defaults in there // Throw some hardcoded defaults in there
await relays.bulkPatch([ await relays.bulkPatch([
{url: 'wss://brb.io'}, {url: "wss://brb.io"},
{url: 'wss://nostr.zebedee.cloud'}, {url: "wss://nostr.zebedee.cloud"},
{url: 'wss://nostr-pub.wellorder.net'}, {url: "wss://nostr-pub.wellorder.net"},
{url: 'wss://relay.nostr.band'}, {url: "wss://relay.nostr.band"},
{url: 'wss://nostr.pleb.network'}, {url: "wss://nostr.pleb.network"},
{url: 'wss://relay.nostrich.de'}, {url: "wss://relay.nostrich.de"},
{url: 'wss://relay.damus.io'}, {url: "wss://relay.damus.io"},
]) ])
// Load relays from nostr.watch via dufflepud // Load relays from nostr.watch via dufflepud
try { try {
const url = import.meta.env.VITE_DUFFLEPUD_URL + '/relay' const url = import.meta.env.VITE_DUFFLEPUD_URL + "/relay"
const json = await fetchJson(url) const json = await fetchJson(url)
await relays.bulkPatch(map(objOf('url'), json.relays.filter(isRelay))) await relays.bulkPatch(map(objOf("url"), json.relays.filter(isRelay)))
} catch (e) { } catch (e) {
warn("Failed to fetch relays list", e) warn("Failed to fetch relays list", e)
} }
@ -52,49 +52,50 @@ const _getPubkeyRelaysCache = new LRUCache({max: 1000})
export const getPubkeyRelays = (pubkey, mode = null, routesOverride = null) => { export const getPubkeyRelays = (pubkey, mode = null, routesOverride = null) => {
const filter = mode ? {pubkey, mode} : {pubkey} const filter = mode ? {pubkey, mode} : {pubkey}
const key = [mode, pubkey].join(':') const key = [mode, pubkey].join(":")
let result = routesOverride || _getPubkeyRelaysCache.get(key) let result = routesOverride || _getPubkeyRelaysCache.get(key)
if (!result) { if (!result) {
result = routes.all(filter) result = routes.all(filter)
_getPubkeyRelaysCache.set(key, result) _getPubkeyRelaysCache.set(key, result)
} }
return sortByScore(map(pick(['url', 'score']), result)) return sortByScore(map(pick(["url", "score"]), result))
} }
export const getPubkeyReadRelays = pubkey => getPubkeyRelays(pubkey, 'read') export const getPubkeyReadRelays = pubkey => getPubkeyRelays(pubkey, "read")
export const getPubkeyWriteRelays = pubkey => getPubkeyRelays(pubkey, 'write') export const getPubkeyWriteRelays = pubkey => getPubkeyRelays(pubkey, "write")
// Multiple pubkeys // Multiple pubkeys
export const getAllPubkeyRelays = (pubkeys, mode = null) => { export const getAllPubkeyRelays = (pubkeys, mode = null) => {
// As an optimization, filter the database once and group by pubkey // As an optimization, filter the database once and group by pubkey
const filter = mode ? {pubkey: pubkeys, mode} : {pubkey: pubkeys} const filter = mode ? {pubkey: pubkeys, mode} : {pubkey: pubkeys}
const routesByPubkey = groupBy(prop('pubkey'), routes.all(filter)) const routesByPubkey = groupBy(prop("pubkey"), routes.all(filter))
return aggregateScores( return aggregateScores(
pubkeys.map( pubkeys.map(pubkey => getPubkeyRelays(pubkey, mode, routesByPubkey[pubkey] || []))
pubkey => getPubkeyRelays(pubkey, mode, routesByPubkey[pubkey] || [])
)
) )
} }
export const getAllPubkeyReadRelays = pubkeys => getAllPubkeyRelays(pubkeys, 'read') export const getAllPubkeyReadRelays = pubkeys => getAllPubkeyRelays(pubkeys, "read")
export const getAllPubkeyWriteRelays = pubkeys => getAllPubkeyRelays(pubkeys, 'write') export const getAllPubkeyWriteRelays = pubkeys => getAllPubkeyRelays(pubkeys, "write")
// Current user // Current user
export const getUserRelays = () => export const getUserRelays = () => user.getRelays().map(assoc("score", 1))
user.getRelays().map(assoc('score', 1))
export const getUserReadRelays = () => export const getUserReadRelays = () =>
getUserRelays().filter(prop('read')).map(pick(['url', 'score'])) getUserRelays()
.filter(prop("read"))
.map(pick(["url", "score"]))
export const getUserWriteRelays = (): Array<Relay> => export const getUserWriteRelays = (): Array<Relay> =>
getUserRelays().filter(prop('write')).map(pick(['url', 'score'])) getUserRelays()
.filter(prop("write"))
.map(pick(["url", "score"]))
// Event-related special cases // Event-related special cases
@ -113,12 +114,10 @@ export const getRelaysForEventParent = event => {
// will write replies there. However, this may include spam, so we may want // 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. // to read from the current user's network's read relays instead.
export const getRelaysForEventChildren = event => { export const getRelaysForEventChildren = event => {
return uniqByUrl(getPubkeyReadRelays(event.pubkey) return uniqByUrl(getPubkeyReadRelays(event.pubkey).concat({url: event.seen_on, score: 1}))
.concat({url: event.seen_on, score: 1}))
} }
export const getRelayForEventHint = event => export const getRelayForEventHint = event => ({url: event.seen_on, score: 1})
({url: event.seen_on, score: 1})
export const getRelayForPersonHint = (pubkey, event) => export const getRelayForPersonHint = (pubkey, event) =>
first(getPubkeyWriteRelays(pubkey)) || getRelayForEventHint(event) first(getPubkeyWriteRelays(pubkey)) || getRelayForEventHint(event)
@ -135,15 +134,14 @@ export const getEventPublishRelays = event => {
return uniqByUrl(aggregateScores(relayChunks).concat(getUserWriteRelays())) return uniqByUrl(aggregateScores(relayChunks).concat(getUserWriteRelays()))
} }
// Utils // Utils
export const uniqByUrl = pipe(uniqBy(prop('url')), filter(prop('url'))) export const uniqByUrl = pipe(uniqBy(prop("url")), filter(prop("url")))
export const sortByScore = sortBy(r => -r.score) export const sortByScore = sortBy(r => -r.score)
export const sampleRelays = (relays, scale = 1) => { export const sampleRelays = (relays, scale = 1) => {
let limit = user.getSetting('relayLimit') let limit = user.getSetting("relayLimit")
// Allow the caller to scale down how many relays we're bothering depending on // Allow the caller to scale down how many relays we're bothering depending on
// the use case, but only if we have enough relays to handle it // the use case, but only if we have enough relays to handle it
@ -159,21 +157,22 @@ export const sampleRelays = (relays, scale = 1) => {
// If we're still under the limit, add user relays for good measure // If we're still under the limit, add user relays for good measure
if (relays.length < limit) { if (relays.length < limit) {
relays = relays.concat( relays = relays.concat(shuffle(getUserReadRelays()).slice(0, limit - relays.length))
shuffle(getUserReadRelays()).slice(0, limit - relays.length)
)
} }
return uniqByUrl(relays) return uniqByUrl(relays)
} }
export const aggregateScores = relayGroups => { export const aggregateScores = relayGroups => {
const scores = {} as Record<string, { const scores = {} as Record<
score: number, string,
count: number, {
weight?: number, score: number
weightedScore?: number count: number
}> weight?: number
weightedScore?: number
}
>
for (const relays of relayGroups) { for (const relays of relayGroups) {
for (const relay of relays) { for (const relay of relays) {
@ -195,7 +194,6 @@ export const aggregateScores = relayGroups => {
} }
return sortByScore( return sortByScore(
Object.entries(scores) Object.entries(scores).map(([url, {weightedScore}]) => ({url, score: weightedScore}))
.map(([url, {weightedScore}]) => ({url, score: weightedScore}))
) )
} }

View File

@ -1,7 +1,7 @@
import {uniq, without} from 'ramda' import {uniq, without} from "ramda"
import {Tags} from 'src/util/nostr' import {Tags} from "src/util/nostr"
import {getPersonWithFallback} from 'src/agent/state' import {getPersonWithFallback} from "src/agent/tables"
import user from 'src/agent/user' import user from "src/agent/user"
export const getFollows = pubkey => export const getFollows = pubkey =>
Tags.wrap(getPersonWithFallback(pubkey).petnames).type("p").values().all() Tags.wrap(getPersonWithFallback(pubkey).petnames).type("p").values().all()
@ -12,8 +12,7 @@ export const getNetwork = pubkey => {
return uniq(without(follows, follows.flatMap(getFollows))) return uniq(without(follows, follows.flatMap(getFollows)))
} }
export const getUserFollows = (): Array<string> => export const getUserFollows = (): Array<string> => Tags.wrap(user.getPetnames()).values().all()
Tags.wrap(user.getPetnames()).values().all()
export const getUserNetwork = () => { export const getUserNetwork = () => {
const follows = getUserFollows() const follows = getUserFollows()

View File

@ -1,20 +1,23 @@
import {error} from 'src/util/logger' import type {Writable} from "svelte/store"
import LRUCache from "lru-cache"
import {throttle} from "throttle-debounce"
import {objOf, is, without} from "ramda"
import {writable} from "svelte/store"
import {isObject, mapValues, ensurePlural} from "hurdak/lib/hurdak"
import {log, error} from "src/util/logger"
import {where} from "src/util/misc"
// Types // ----------------------------------------------------------------------------
// Localforage interface via web worker
type Message = { type Message = {
topic: string topic: string
payload: object payload: object
} }
// Plumbing const worker = new Worker(new URL("./workers/database.js", import.meta.url), {type: "module"})
const worker = new Worker( worker.addEventListener("error", error)
new URL('./workers/database.js', import.meta.url),
{type: 'module'}
)
worker.addEventListener('error', error)
class Channel { class Channel {
id: string id: string
@ -27,10 +30,10 @@ class Channel {
} }
} }
worker.addEventListener('message', this.onMessage) worker.addEventListener("message", this.onMessage)
} }
close() { close() {
worker.removeEventListener('message', this.onMessage) worker.removeEventListener("message", this.onMessage)
} }
send(topic, payload) { send(topic, payload) {
worker.postMessage({channel: this.id, topic, payload}) worker.postMessage({channel: this.id, topic, payload})
@ -50,12 +53,209 @@ const call = (topic, payload): Promise<Message> => {
}) })
} }
export const lf = async (method, ...args) => { const lf = async (method, ...args) => {
const message = await call('localforage.call', {method, args}) const message = await call("localforage.call", {method, args})
if (message.topic !== 'localforage.return') { if (message.topic !== "localforage.return") {
throw new Error(`callLocalforage received invalid response: ${message}`) throw new Error(`callLocalforage received invalid response: ${message}`)
} }
return message.payload return message.payload
} }
export const setItem = (k, v) => lf("setItem", k, v)
export const removeItem = k => lf("removeItem", k)
export const getItem = k => lf("getItem", k)
// ----------------------------------------------------------------------------
// Database table abstraction, synced to worker storage
type CacheEntry = [string, {value: any}]
type TableOpts = {
maxEntries?: number
initialize?: (table: Table) => Promise<Array<CacheEntry>>
}
export const registry = {} as Record<string, Table>
export class Table {
name: string
pk: string
opts: TableOpts
cache: LRUCache<string, any>
listeners: Array<(Table) => void>
ready: Writable<boolean>
constructor(name, pk, opts: TableOpts = {}) {
this.name = name
this.pk = pk
this.opts = {maxEntries: 1000, initialize: t => this.dump(), ...opts}
this.cache = new LRUCache({max: this.opts.maxEntries})
this.listeners = []
this.ready = writable(false)
registry[name] = this
// Sync from storage initially
;(async () => {
const t = Date.now()
this.cache.load((await this.opts.initialize(this)) || [])
this._notify()
log(`Table ${name} ready in ${Date.now() - t}ms (${this.cache.size} records)`)
this.ready.set(true)
})()
}
_persist = throttle(4_000, () => {
setItem(this.name, this.cache.dump())
})
_notify() {
// Notify subscribers
for (const cb of this.listeners) {
cb(this)
}
// Save to localstorage
this._persist()
}
subscribe(cb) {
cb = throttle(100, cb)
this.listeners.push(cb)
cb(this)
return () => {
this.listeners = without([cb], this.listeners)
}
}
bulkPut(items) {
for (const item of items) {
const k = item[this.pk]
if (!k) {
throw new Error(`Missing primary key on ${this.name}`)
}
this.cache.set(k, item)
}
this._persist()
}
put(item) {
this.bulkPut([item])
}
bulkPatch(items) {
for (const item of items) {
const k = item[this.pk]
if (!k) {
throw new Error(`Missing primary key on ${this.name}`)
}
this.cache.set(k, {...this.cache.get(k), ...item})
}
this._persist()
}
patch(item) {
this.bulkPatch([item])
}
bulkRemove(ks) {
for (const k of ks) {
this.cache.delete(k)
}
this._persist()
}
remove(k) {
this.bulkRemove([k])
}
async drop() {
this.cache.clear()
return removeItem(this.name)
}
async dump() {
let data = (await getItem(this.name)) || []
// Backwards compat - we used to store objects rather than cache dump arrays
if (isObject(data)) {
data = Object.entries(mapValues(objOf("value"), data))
}
return data as Array<CacheEntry>
}
toArray() {
const result = []
for (const item of this.cache.values()) {
result.push(item)
}
return result
}
all(spec = {}) {
return this.toArray().filter(where(spec))
}
find(spec = {}) {
return this.cache.find(where(spec))
}
get(k) {
return this.cache.get(k)
}
}
const listener = (() => {
let listeners = []
for (const table of Object.values(registry) as Array<Table>) {
table.subscribe(() => listeners.forEach(f => f(table.name)))
}
return {
subscribe: f => {
listeners.push(f)
return () => {
listeners = without([f], listeners)
}
},
}
})()
export const watch = (names, f) => {
names = ensurePlural(names)
const store = writable(null)
const tables = names.map(name => registry[name])
// Initialize synchronously if possible
const initialValue = f(...tables)
if (is(Promise, initialValue)) {
initialValue.then(v => store.set(v))
} else {
store.set(initialValue)
}
// Debounce refresh so we don't get UI lag
const refresh = throttle(300, async () => store.set(await f(...tables)))
// Listen for changes
listener.subscribe(name => {
if (names.includes(name)) {
refresh()
}
})
return store
}
export const dropAll = async () => {
for (const table of Object.values(registry)) {
await table.drop()
log(`Successfully dropped table ${table.name}`)
}
}

View File

@ -12,25 +12,28 @@ import {
timedelta, timedelta,
hash, hash,
} from "src/util/misc" } from "src/util/misc"
import { import {Tags, roomAttrs, isRelay, isShareableRelay, normalizeRelayUrl} from "src/util/nostr"
Tags, import {people, userEvents, relays, rooms, routes} from "src/agent/tables"
roomAttrs,
isRelay,
isShareableRelay,
normalizeRelayUrl,
} from "src/util/nostr"
import {getPersonWithFallback, people, relays, rooms, routes} from "src/agent/state"
import {uniqByUrl} from "src/agent/relays" import {uniqByUrl} from "src/agent/relays"
import user from "src/agent/user"
const handlers = {} const handlers = {}
const addHandler = (kind, f) => (handlers[kind] || []).push(f) const addHandler = (kind, f) => {
handlers[kind] = handlers[kind] || []
handlers[kind].push(f)
}
const processEvents = async events => { const processEvents = async events => {
const userPubkey = user.getPubkey()
const chunks = chunk(100, ensurePlural(events)) const chunks = chunk(100, ensurePlural(events))
for (let i = 0; i < chunks.length; i++) { for (let i = 0; i < chunks.length; i++) {
for (const event of chunks[i]) { for (const event of chunks[i]) {
if (event.pubkey === userPubkey) {
userEvents.put(event)
}
for (const handler of handlers[event.kind] || []) { for (const handler of handlers[event.kind] || []) {
handler(event) handler(event)
} }
@ -45,12 +48,19 @@ const processEvents = async events => {
// People // People
const updatePerson = (pubkey, data) => {
people.patch({pubkey, updated_at: now(), ...data})
// If our pubkey matches, copy to our user's profile as well
if (pubkey === user.getPubkey()) {
user.profile.update($p => ({...$p, ...data}))
}
}
const verifyNip05 = (pubkey, as) => const verifyNip05 = (pubkey, as) =>
nip05.queryProfile(as).then(result => { nip05.queryProfile(as).then(result => {
if (result?.pubkey === pubkey) { if (result?.pubkey === pubkey) {
const person = getPersonWithFallback(pubkey) updatePerson(pubkey, {verified_as: as})
people.patch({...person, verified_as: as})
if (result.relays?.length > 0) { if (result.relays?.length > 0) {
const urls = result.relays.filter(isRelay) const urls = result.relays.filter(isRelay)
@ -92,7 +102,7 @@ const verifyZapper = async (pubkey, address) => {
const lnurl = lnurlEncode("lnurl", url) const lnurl = lnurlEncode("lnurl", url)
if (zapper?.allowsNostr && zapper?.nostrPubkey) { if (zapper?.allowsNostr && zapper?.nostrPubkey) {
people.patch({pubkey, zapper, lnurl}) updatePerson(pubkey, {zapper, lnurl})
} }
} }
@ -116,48 +126,59 @@ addHandler(0, e => {
verifyZapper(e.pubkey, address.toLowerCase()) verifyZapper(e.pubkey, address.toLowerCase())
} }
people.patch({ updatePerson(e.pubkey, {
pubkey: e.pubkey,
updated_at: now(),
kind0: {...person?.kind0, ...kind0}, kind0: {...person?.kind0, ...kind0},
kind0_updated_at: e.created_at, kind0_updated_at: e.created_at,
}) })
}) })
}) })
addHandler(2, e => {
const person = people.get(e.pubkey)
if (e.created_at < person?.relays_updated_at) {
return
}
people.patch({
pubkey: e.pubkey,
updated_at: now(),
relays_updated_at: e.created_at,
relays: uniqByUrl((person?.relays || []).concat({url: e.content})),
})
})
addHandler(3, e => { addHandler(3, e => {
const person = people.get(e.pubkey) const person = people.get(e.pubkey)
if (e.created_at > (person?.petnames_updated_at || 0)) { if (e.created_at < person?.petnames_updated_at) {
people.patch({ return
pubkey: e.pubkey,
updated_at: now(),
petnames_updated_at: e.created_at,
petnames: e.tags.filter(t => t[0] === "p"),
})
} }
if (e.created_at > (person.relays_updated_at || 0)) { updatePerson(e.pubkey, {
tryJson(() => { petnames_updated_at: e.created_at,
people.patch({ petnames: e.tags.filter(t => t[0] === "p"),
pubkey: e.pubkey, })
relays_updated_at: e.created_at, })
relays: Object.entries(JSON.parse(e.content))
// User profile, except for events also handled for other users
const profileHandler = (key, getValue) => e => {
const profile = user.getProfile()
if (e.pubkey !== profile.pubkey) {
return
}
const updated_at_key = `${key}_updated_at`
if (e.created_at < profile?.[updated_at_key]) {
return
}
user.profile.update($p => ({
...$p,
[key]: getValue(e, $p),
[updated_at_key]: e.created_at,
}))
}
addHandler(
2,
profileHandler("relays", (e, p) => uniqByUrl(p.relays.concat({url: e.content})))
)
addHandler(
3,
profileHandler("relays", (e, p) => {
return (
tryJson(() => {
return Object.entries(JSON.parse(e.content))
.map(([url, conditions]) => { .map(([url, conditions]) => {
const {write, read} = conditions as Record<string, boolean | string> const {write, read} = conditions as Record<string, boolean | string>
@ -167,62 +188,36 @@ addHandler(3, e => {
read: [false, "!"].includes(read) ? false : true, read: [false, "!"].includes(read) ? false : true,
} }
}) })
.filter(r => isRelay(r.url)), .filter(r => isRelay(r.url))
}) }) || p.relays
}) )
}
})
addHandler(10000, e => {
const person = people.get(e.pubkey)
if (e.created_at < person?.mutes_updated_at) {
return
}
people.patch({
pubkey: e.pubkey,
updated_at: now(),
mutes_updated_at: e.created_at,
mutes: e.tags,
}) })
}) )
addHandler(
10000,
profileHandler("mutes", (e, p) => e.tags)
)
// DEPRECATED // DEPRECATED
addHandler(12165, e => { addHandler(
const person = people.get(e.pubkey) 10001,
profileHandler("relays", (e, p) => {
if (e.created_at < person?.mutes_updated_at) { return e.tags.map(([url, read, write]) => ({url, read: read !== "!", write: write !== "!"}))
return
}
people.patch({
pubkey: e.pubkey,
updated_at: now(),
mutes_updated_at: e.created_at,
mutes: e.tags,
}) })
}) )
addHandler(10002, e => { addHandler(
const person = people.get(e.pubkey) 10002,
profileHandler("relays", (e, p) => {
if (e.created_at < person?.relays_updated_at) { return e.tags.map(([_, url, mode]) => {
return
}
people.patch({
pubkey: e.pubkey,
updated_at: now(),
relays_updated_at: e.created_at,
relays: e.tags.map(([_, url, mode]) => {
const read = (mode || "read") === "read" const read = (mode || "read") === "read"
const write = (mode || "write") === "write" const write = (mode || "write") === "write"
return {url, read, write} return {url, read, write}
}), })
}) })
}) )
// Rooms // Rooms

View File

@ -1,208 +0,0 @@
import type {Writable} from 'svelte/store'
import LRUCache from 'lru-cache'
import {throttle} from 'throttle-debounce'
import {objOf, is, without} from 'ramda'
import {writable} from 'svelte/store'
import {isObject, mapValues, ensurePlural} from 'hurdak/lib/hurdak'
import {log} from 'src/util/logger'
import {where} from 'src/util/misc'
import {lf} from 'src/agent/storage'
// Local copy of data so we can provide a sync observable interface. The worker
// is just for storing data and processing expensive queries
type CacheEntry = [string, {value: any}]
type TableOpts = {
maxEntries?: number
initialize?: (table: Table) => Promise<Array<CacheEntry>>
}
export const registry = {} as Record<string, Table>
export class Table {
name: string
pk: string
opts: TableOpts
cache: LRUCache<string, any>
listeners: Array<(Table) => void>
ready: Writable<boolean>
constructor(name, pk, opts: TableOpts = {}) {
this.name = name
this.pk = pk
this.opts = {maxEntries: 1000, initialize: t => this.dump(), ...opts}
this.cache = new LRUCache({max: this.opts.maxEntries})
this.listeners = []
this.ready = writable(false)
registry[name] = this
// Sync from storage initially
;(async () => {
const t = Date.now()
this.cache.load(await this.opts.initialize(this) || [])
this._notify()
log(`Table ${name} ready in ${Date.now() - t}ms (${this.cache.size} records)`)
this.ready.set(true)
})()
}
_persist = throttle(4_000, () => {
lf('setItem', this.name, this.cache.dump())
})
_notify() {
// Notify subscribers
for (const cb of this.listeners) {
cb(this)
}
// Save to localstorage
this._persist()
}
subscribe(cb) {
cb = throttle(100, cb)
this.listeners.push(cb)
cb(this)
return () => {
this.listeners = without([cb], this.listeners)
}
}
bulkPut(items) {
for (const item of items) {
const k = item[this.pk]
if (!k) {
throw new Error(`Missing primary key on ${this.name}`)
}
this.cache.set(k, item)
}
this._persist()
}
put(item) {
this.bulkPut([item])
}
bulkPatch(items) {
for (const item of items) {
const k = item[this.pk]
if (!k) {
throw new Error(`Missing primary key on ${this.name}`)
}
this.cache.set(k, {...this.cache.get(k), ...item})
}
this._persist()
}
patch(item) {
this.bulkPatch([item])
}
bulkRemove(ks) {
for (const k of ks) {
this.cache.delete(k)
}
this._persist()
}
remove(k) {
this.bulkRemove([k])
}
async drop() {
this.cache.clear()
return lf('removeItem', this.name)
}
async dump() {
let data = await lf('getItem', this.name) || []
// Backwards compat - we used to store objects rather than cache dump arrays
if (isObject(data)) {
data = Object.entries(mapValues(objOf('value'), data))
}
return data as Array<CacheEntry>
}
toArray() {
const result = []
for (const item of this.cache.values()) {
result.push(item)
}
return result
}
all(spec = {}) {
return this.toArray().filter(where(spec))
}
find(spec = {}) {
return this.cache.find(where(spec))
}
get(k) {
return this.cache.get(k)
}
}
// Helper to allow us to listen to changes of any given table
const listener = (() => {
let listeners = []
for (const table of Object.values(registry) as Array<Table>) {
table.subscribe(() => listeners.forEach(f => f(table.name)))
}
return {
subscribe: f => {
listeners.push(f)
return () => {
listeners = without([f], listeners)
}
},
}
})()
// Helper to re-run a query every time a given table changes
export const watch = (names, f) => {
names = ensurePlural(names)
const store = writable(null)
const tables = names.map(name => registry[name])
// Initialize synchronously if possible
const initialValue = f(...tables)
if (is(Promise, initialValue)) {
initialValue.then(v => store.set(v))
} else {
store.set(initialValue)
}
// Debounce refresh so we don't get UI lag
const refresh = throttle(300, async () => store.set(await f(...tables)))
// Listen for changes
listener.subscribe(name => {
if (names.includes(name)) {
refresh()
}
})
return store
}
// Methods that work on all tables
export const dropAll = async () => {
for (const table of Object.values(registry)) {
await table.drop()
log(`Successfully dropped table ${table.name}`)
}
}

View File

@ -1,7 +1,8 @@
import {pluck, all, identity} from "ramda" import {pluck, all, identity} from "ramda"
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {Table, registry} from "src/agent/table" import {Table, registry} from "src/agent/storage"
export const userEvents = new Table("userEvents", "id")
export const people = new Table("people", "pubkey") export const people = new Table("people", "pubkey")
export const contacts = new Table("contacts", "pubkey") export const contacts = new Table("contacts", "pubkey")
export const rooms = new Table("rooms", "id") export const rooms = new Table("rooms", "id")
@ -21,5 +22,3 @@ export const onReady = cb => {
} }
}) })
} }
(window as any).t = {people, contacts, rooms, alerts, relays, routes}

View File

@ -1,91 +1,56 @@
import type {Person} from 'src/util/types' import type {Relay} from "src/util/types"
import type {Readable} from 'svelte/store' import type {Readable} from "svelte/store"
import {slice, identity, prop, find, pipe, assoc, whereEq, when, concat, reject, nth, map} from 'ramda' import {slice, prop, find, pipe, assoc, whereEq, when, concat, reject, nth, map} from "ramda"
import {findReplyId, findRootId} from 'src/util/nostr' import {findReplyId, findRootId} from "src/util/nostr"
import {synced} from 'src/util/misc' import {synced} from "src/util/misc"
import {derived} from 'svelte/store' import {derived} from "svelte/store"
import {people} from 'src/agent/state' import keys from "src/agent/keys"
import keys from 'src/agent/keys' import cmd from "src/agent/cmd"
import cmd from 'src/agent/cmd'
// Create a special wrapper to manage profile data, follows, and relays in the same const profile = synced("agent/user/profile", {
// way whether the user is logged in or not. This involves creating a store that we pubkey: null,
// allow an anonymous user to write to, then once the user logs in we use that until kind0: null,
// we have actual event data for them, which we then prefer. For extra fun, we also lnurl: null,
// sync this stuff to regular private variables so we don't have to constantly call zapper: null,
// `get` on our stores. settings: {
relayLimit: 20,
let settingsCopy = null defaultZap: 21,
let profileCopy = null showMedia: true,
let petnamesCopy = [] reportAnalytics: true,
let relaysCopy = [] dufflepudUrl: import.meta.env.VITE_DUFFLEPUD_URL,
let mutesCopy = [] },
petnames: [],
const anonPetnames = synced('agent/user/anonPetnames', []) relays: [],
const anonRelays = synced('agent/user/anonRelays', []) mutes: [],
const anonMutes = synced('agent/user/anonMutes', [])
const settings = synced("agent/user/settings", {
relayLimit: 20,
defaultZap: 21,
showMedia: true,
reportAnalytics: true,
dufflepudUrl: import.meta.env.VITE_DUFFLEPUD_URL,
}) })
const profile = derived( const settings = derived(profile, prop("settings"))
[keys.pubkey, people as Readable<any>], const petnames = derived(profile, prop("petnames"))
([pubkey, t]) => pubkey ? (t.get(pubkey) || {pubkey}) : null const relays = derived(profile, prop("relays")) as Readable<Array<Relay>>
) as Readable<Person> const mutes = derived(profile, prop("mutes"))
const profileKeyWithDefault = (key, stores) => derived(
[profile, ...stores],
([$profile, ...values]) =>
$profile?.[key] || find(identity, values)
)
const petnames = profileKeyWithDefault('petnames', [anonPetnames])
const relays = profileKeyWithDefault('relays', [anonRelays])
// Backwards compat, migrate muffle to mute temporarily
const mutes = profileKeyWithDefault('mutes', [anonMutes, derived(profile, prop('muffle'))])
const canPublish = derived( const canPublish = derived(
[keys.pubkey, relays], [keys.pubkey, relays],
([$pubkey, $relays]) => ([$pubkey, $relays]) => keys.canSign() && find(prop("write"), $relays)
keys.canSign() && find(prop('write'), $relays)
) )
// Keep our copies up to date // Keep a copy so we can avoid calling `get` all the time
settings.subscribe($settings => { let profileCopy = null
settingsCopy = $settings
})
profile.subscribe($profile => { profile.subscribe($profile => {
profileCopy = $profile profileCopy = $profile
}) })
petnames.subscribe($petnames => { // Watch pubkey and add to profile
petnamesCopy = $petnames
keys.pubkey.subscribe($pubkey => {
if ($pubkey) {
profile.update($p => ({...$p, pubkey: $pubkey}))
}
}) })
mutes.subscribe($mutes => { export default {
mutesCopy = $mutes
})
relays.subscribe($relays => {
relaysCopy = $relays
})
const user = {
// Settings
settings,
getSettings: () => settingsCopy,
getSetting: k => settingsCopy[k],
dufflepud: path => `${settingsCopy.dufflepudUrl}${path}`,
// Profile // Profile
profile, profile,
@ -93,24 +58,36 @@ const user = {
getProfile: () => profileCopy, getProfile: () => profileCopy,
getPubkey: () => profileCopy?.pubkey, getPubkey: () => profileCopy?.pubkey,
// Settings
settings,
getSettings: () => profileCopy.settings,
getSetting: k => profileCopy.settings[k],
dufflepud: path => `${profileCopy.settings.dufflepudUrl}${path}`,
// Petnames // Petnames
petnames, petnames,
getPetnames: () => petnamesCopy, getPetnames: () => profileCopy.petnames,
petnamePubkeys: derived(petnames, map(nth(1))) as Readable<Array<string>>, petnamePubkeys: derived(petnames, map(nth(1))) as Readable<Array<string>>,
updatePetnames(f) { updatePetnames(f) {
const $petnames = f(petnamesCopy) const $petnames = f(profileCopy.petnames)
anonPetnames.set($petnames) profile.update(assoc("petnames", $petnames))
if (profileCopy) { if (profileCopy) {
return cmd.setPetnames($petnames).publish(relaysCopy) return cmd.setPetnames($petnames).publish(profileCopy.relays)
} }
}, },
addPetname(pubkey, url, name) { addPetname(pubkey, url, name) {
const tag = ["p", pubkey, url, name || ""] const tag = ["p", pubkey, url, name || ""]
return this.updatePetnames(pipe(reject(t => t[1] === pubkey), concat([tag]))) return this.updatePetnames(
pipe(
reject(t => t[1] === pubkey),
concat([tag])
)
)
}, },
removePetname(pubkey) { removePetname(pubkey) {
return this.updatePetnames(reject(t => t[1] === pubkey)) return this.updatePetnames(reject(t => t[1] === pubkey))
@ -119,11 +96,11 @@ const user = {
// Relays // Relays
relays, relays,
getRelays: () => relaysCopy, getRelays: () => profileCopy.relays,
updateRelays(f) { updateRelays(f) {
const $relays = f(relaysCopy) const $relays = f(profileCopy.relays)
anonRelays.set($relays) profile.update(assoc("relays", $relays))
if (profileCopy) { if (profileCopy) {
return cmd.setRelays($relays).publish($relays) return cmd.setRelays($relays).publish($relays)
@ -136,28 +113,27 @@ const user = {
return this.updateRelays(reject(whereEq({url}))) return this.updateRelays(reject(whereEq({url})))
}, },
setRelayWriteCondition(url, write) { setRelayWriteCondition(url, write) {
return this.updateRelays(map(when(whereEq({url}), assoc('write', write)))) return this.updateRelays(map(when(whereEq({url}), assoc("write", write))))
}, },
// Mutes // Mutes
mutes, mutes,
getMutes: () => mutesCopy, getMutes: () => profileCopy.mutes,
applyMutes: events => { applyMutes: events => {
const m = new Set(mutesCopy.map(m => m[1])) const m = new Set(profileCopy.mutes.map(m => m[1]))
return events.filter(e => return events.filter(
!(m.has(e.id) || m.has(e.pubkey) || m.has(findReplyId(e)) || m.has(findRootId(e))) e => !(m.has(e.id) || m.has(e.pubkey) || m.has(findReplyId(e)) || m.has(findRootId(e)))
) )
}, },
updateMutes(f) { updateMutes(f) {
const $mutes = f(mutesCopy) const $mutes = f(profileCopy.mutes)
console.log(mutesCopy, $mutes)
anonMutes.set($mutes) profile.update(assoc("mutes", $mutes))
if (profileCopy) { if (profileCopy) {
return cmd.setMutes($mutes.map(slice(0, 2))).publish(relaysCopy) return cmd.setMutes($mutes.map(slice(0, 2))).publish(profileCopy.relays)
} }
}, },
addMute(type, value) { addMute(type, value) {
@ -172,5 +148,3 @@ const user = {
return this.updateMutes(reject(t => t[1] === pubkey)) return this.updateMutes(reject(t => t[1] === pubkey))
}, },
} }
export default user

View File

@ -1,13 +1,13 @@
import type {DisplayEvent} from 'src/util/types' import type {DisplayEvent} from "src/util/types"
import {max, find, pluck, propEq, partition, uniq} from 'ramda' import {max, find, pluck, propEq, partition, uniq} from "ramda"
import {derived} from 'svelte/store' import {derived} from "svelte/store"
import {createMap} from 'hurdak/lib/hurdak' import {createMap} from "hurdak/lib/hurdak"
import {synced, tryJson, now, timedelta} from 'src/util/misc' import {synced, tryJson, now, timedelta} from "src/util/misc"
import {Tags, personKinds, isAlert, asDisplayEvent, findReplyId} from 'src/util/nostr' import {Tags, userKinds, isAlert, asDisplayEvent, findReplyId} from "src/util/nostr"
import {getUserReadRelays} from 'src/agent/relays' import {getUserReadRelays} from "src/agent/relays"
import {alerts, contacts, rooms} from 'src/agent/state' import {alerts, contacts, rooms} from "src/agent/tables"
import {watch} from 'src/agent/table' import {watch} from "src/agent/storage"
import network from 'src/agent/network' import network from "src/agent/network"
let listener let listener
@ -20,25 +20,23 @@ type AlertEvent = DisplayEvent & {
// State // State
const seenAlertIds = synced('app/alerts/seenAlertIds', []) const seenAlertIds = synced("app/alerts/seenAlertIds", [])
export const lastChecked = synced('app/alerts/lastChecked', {}) export const lastChecked = synced("app/alerts/lastChecked", {})
export const newAlerts = derived( export const newAlerts = derived(
[watch('alerts', t => pluck('created_at', t.all()).reduce(max, 0)), lastChecked], [watch("alerts", t => pluck("created_at", t.all()).reduce(max, 0)), lastChecked],
([$lastAlert, $lastChecked]) => $lastAlert > ($lastChecked.alerts || 0) ([$lastAlert, $lastChecked]) => $lastAlert > ($lastChecked.alerts || 0)
) )
export const newDirectMessages = derived( export const newDirectMessages = derived(
[watch('contacts', t => t.all()), lastChecked], [watch("contacts", t => t.all()), lastChecked],
([contacts, $lastChecked]) => ([contacts, $lastChecked]) => Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], contacts))
Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], contacts))
) )
export const newChatMessages = derived( export const newChatMessages = derived(
[watch('rooms', t => t.all()), lastChecked], [watch("rooms", t => t.all()), lastChecked],
([rooms, $lastChecked]) => ([rooms, $lastChecked]) => Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], rooms))
Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], rooms))
) )
// Synchronization from events to state // Synchronization from events to state
@ -59,10 +57,15 @@ const processAlerts = async (pubkey, events) => {
return return
} }
const parents = createMap('id', await network.loadParents(events)) const parents = createMap("id", await network.loadParents(events))
const asAlert = (e): AlertEvent => const asAlert = (e): AlertEvent => ({
({repliesFrom: [], likedBy: [], zappedBy: [], isMention: false, ...asDisplayEvent(e)}) repliesFrom: [],
likedBy: [],
zappedBy: [],
isMention: false,
...asDisplayEvent(e),
})
const isPubkeyChild = e => { const isPubkeyChild = e => {
const parentId = findReplyId(e) const parentId = findReplyId(e)
@ -70,9 +73,9 @@ const processAlerts = async (pubkey, events) => {
return parents[parentId]?.pubkey === pubkey return parents[parentId]?.pubkey === pubkey
} }
const [replies, mentions] = partition(isPubkeyChild, events.filter(propEq('kind', 1))) const [replies, mentions] = partition(isPubkeyChild, events.filter(propEq("kind", 1)))
const likes = events.filter(propEq('kind', 7)) const likes = events.filter(propEq("kind", 7))
const zaps = events.filter(propEq('kind', 9735)) const zaps = events.filter(propEq("kind", 9735))
zaps.filter(isPubkeyChild).forEach(e => { zaps.filter(isPubkeyChild).forEach(e => {
const parent = parents[findReplyId(e)] const parent = parents[findReplyId(e)]
@ -107,7 +110,7 @@ const processAlerts = async (pubkey, events) => {
} }
const processMessages = async (pubkey, events) => { const processMessages = async (pubkey, events) => {
const messages = events.filter(propEq('kind', 4)) const messages = events.filter(propEq("kind", 4))
if (messages.length === 0) { if (messages.length === 0) {
return return
@ -133,7 +136,7 @@ const processMessages = async (pubkey, events) => {
} }
const processChats = async (pubkey, events) => { const processChats = async (pubkey, events) => {
const messages = events.filter(propEq('kind', 42)) const messages = events.filter(propEq("kind", 42))
if (messages.length === 0) { if (messages.length === 0) {
return return
@ -159,8 +162,8 @@ const processChats = async (pubkey, events) => {
const listen = async pubkey => { const listen = async pubkey => {
// Include an offset so we don't miss alerts on one relay but not another // Include an offset so we don't miss alerts on one relay but not another
const since = now() - timedelta(7, 'days') const since = now() - timedelta(7, "days")
const roomIds = pluck('id', rooms.all({joined: true})) const roomIds = pluck("id", rooms.all({joined: true}))
if (listener) { if (listener) {
listener.unsub() listener.unsub()
@ -170,13 +173,13 @@ const listen = async pubkey => {
delay: 10000, delay: 10000,
relays: getUserReadRelays(), relays: getUserReadRelays(),
filter: [ filter: [
{kinds: personKinds, authors: [pubkey], since}, {kinds: userKinds, authors: [pubkey], since},
{kinds: [4], authors: [pubkey], since}, {kinds: [4], authors: [pubkey], since},
{kinds: [1, 7, 4, 9735], '#p': [pubkey], since}, {kinds: [1, 7, 4, 9735], "#p": [pubkey], since},
{kinds: [42], '#e': roomIds, since}, {kinds: [42], "#e": roomIds, since},
], ],
onChunk: async events => { onChunk: async events => {
await network.loadPeople(pluck('pubkey', events)) await network.loadPeople(pluck("pubkey", events))
await processMessages(pubkey, events) await processMessages(pubkey, events)
await processAlerts(pubkey, events) await processAlerts(pubkey, events)
await processChats(pubkey, events) await processChats(pubkey, events)

View File

@ -1,15 +1,15 @@
import type {DisplayEvent} from 'src/util/types' import type {DisplayEvent} from "src/util/types"
import {omit, sortBy} from 'ramda' import {omit, sortBy} from "ramda"
import {createMap, ellipsize} from 'hurdak/lib/hurdak' import {createMap, ellipsize} from "hurdak/lib/hurdak"
import {renderContent} from 'src/util/html' import {renderContent} from "src/util/html"
import {displayPerson, findReplyId} from 'src/util/nostr' import {displayPerson, findReplyId} from "src/util/nostr"
import {getUserFollows} from 'src/agent/social' import {getUserFollows} from "src/agent/social"
import {getUserReadRelays} from 'src/agent/relays' import {getUserReadRelays} from "src/agent/relays"
import {getPersonWithFallback} from 'src/agent/state' import {getPersonWithFallback} from "src/agent/tables"
import network from 'src/agent/network' import network from "src/agent/network"
import keys from 'src/agent/keys' import keys from "src/agent/keys"
import alerts from 'src/app/alerts' import alerts from "src/app/alerts"
import {routes, modal, toast} from 'src/app/ui' import {routes, modal, toast} from "src/app/ui"
export const loadAppData = async pubkey => { export const loadAppData = async pubkey => {
if (getUserReadRelays().length > 0) { if (getUserReadRelays().length > 0) {
@ -24,40 +24,37 @@ export const loadAppData = async pubkey => {
export const login = (method, key) => { export const login = (method, key) => {
keys.login(method, key) keys.login(method, key)
modal.set({type: 'login/connect', noEscape: true}) modal.set({type: "login/connect", noEscape: true})
} }
export const renderNote = (note, {showEntire = false}) => { export const renderNote = (note, {showEntire = false}) => {
let content let content
// Ellipsize // Ellipsize
content = note.content.length > 500 && !showEntire content = note.content.length > 500 && !showEntire ? ellipsize(note.content, 500) : note.content
? ellipsize(note.content, 500)
: note.content
// Escape html, replace urls // Escape html, replace urls
content = renderContent(content) content = renderContent(content)
// Mentions // Mentions
content = content content = content.replace(/#\[(\d+)\]/g, (tag, i) => {
.replace(/#\[(\d+)\]/g, (tag, i) => { if (!note.tags[parseInt(i)]) {
if (!note.tags[parseInt(i)]) { return tag
return tag }
}
const pubkey = note.tags[parseInt(i)][1] const pubkey = note.tags[parseInt(i)][1]
const person = getPersonWithFallback(pubkey) const person = getPersonWithFallback(pubkey)
const name = displayPerson(person) const name = displayPerson(person)
const path = routes.person(pubkey) const path = routes.person(pubkey)
return `@<a href="${path}" class="underline">${name}</a>` return `@<a href="${path}" class="underline">${name}</a>`
}) })
return content return content
} }
export const mergeParents = (notes: Array<DisplayEvent>) => { export const mergeParents = (notes: Array<DisplayEvent>) => {
const notesById = createMap('id', notes) as Record<string, DisplayEvent> const notesById = createMap("id", notes) as Record<string, DisplayEvent>
const childIds = [] const childIds = []
for (const note of Object.values(notesById)) { for (const note of Object.values(notesById)) {
@ -94,8 +91,8 @@ export const publishWithToast = (relays, thunk) =>
} }
if (extra.length > 0) { if (extra.length > 0) {
message += ` (${extra.join(', ')})` message += ` (${extra.join(", ")})`
} }
toast.show('info', message, pending.size ? null : 5) toast.show("info", message, pending.size ? null : 5)
}) })

View File

@ -6,7 +6,7 @@
import {sleep, createScroller, Cursor} from "src/util/misc" import {sleep, createScroller, Cursor} from "src/util/misc"
import Spinner from "src/partials/Spinner.svelte" import Spinner from "src/partials/Spinner.svelte"
import user from "src/agent/user" import user from "src/agent/user"
import {getPersonWithFallback} from "src/agent/state" import {getPersonWithFallback} from "src/agent/tables"
import network from "src/agent/network" import network from "src/agent/network"
export let loadMessages export let loadMessages

View File

@ -7,7 +7,7 @@
import {displayPerson} from "src/util/nostr" import {displayPerson} from "src/util/nostr"
import {fromParentOffset} from "src/util/html" import {fromParentOffset} from "src/util/html"
import Badge from "src/partials/Badge.svelte" import Badge from "src/partials/Badge.svelte"
import {people} from "src/agent/state" import {people} from "src/agent/tables"
export let onSubmit export let onSubmit

View File

@ -7,7 +7,7 @@
import Content from "src/partials/Content.svelte" import Content from "src/partials/Content.svelte"
import Alert from "src/views/alerts/Alert.svelte" import Alert from "src/views/alerts/Alert.svelte"
import Mention from "src/views/alerts/Mention.svelte" import Mention from "src/views/alerts/Mention.svelte"
import {alerts} from "src/agent/state" import {alerts} from "src/agent/tables"
import user from "src/agent/user" import user from "src/agent/user"
import {lastChecked} from "src/app/alerts" import {lastChecked} from "src/app/alerts"

View File

@ -9,7 +9,7 @@
import user from "src/agent/user" import user from "src/agent/user"
import {getRelaysForEventChildren, sampleRelays} from "src/agent/relays" import {getRelaysForEventChildren, sampleRelays} from "src/agent/relays"
import network from "src/agent/network" import network from "src/agent/network"
import {watch} from "src/agent/table" import {watch} from "src/agent/storage"
import cmd from "src/agent/cmd" import cmd from "src/agent/cmd"
import {modal} from "src/app/ui" import {modal} from "src/app/ui"
import {lastChecked} from "src/app/alerts" import {lastChecked} from "src/app/alerts"

View File

@ -5,7 +5,7 @@
import Content from "src/partials/Content.svelte" import Content from "src/partials/Content.svelte"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import ChatListItem from "src/views/chat/ChatListItem.svelte" import ChatListItem from "src/views/chat/ChatListItem.svelte"
import {watch} from "src/agent/table" import {watch} from "src/agent/storage"
import network from "src/agent/network" import network from "src/agent/network"
import {getUserReadRelays} from "src/agent/relays" import {getUserReadRelays} from "src/agent/relays"
import {modal} from "src/app/ui" import {modal} from "src/app/ui"

View File

@ -2,7 +2,7 @@
import {fly} from "svelte/transition" import {fly} from "svelte/transition"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import Content from "src/partials/Content.svelte" import Content from "src/partials/Content.svelte"
import {dropAll} from "src/agent/table" import {dropAll} from "src/agent/storage"
let confirmed = false let confirmed = false

View File

@ -8,8 +8,8 @@
import Channel from "src/partials/Channel.svelte" import Channel from "src/partials/Channel.svelte"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import {getAllPubkeyRelays, sampleRelays} from "src/agent/relays" import {getAllPubkeyRelays, sampleRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/state" import {getPersonWithFallback} from "src/agent/tables"
import {watch} from "src/agent/table" import {watch} from "src/agent/storage"
import network from "src/agent/network" import network from "src/agent/network"
import keys from "src/agent/keys" import keys from "src/agent/keys"
import user from "src/agent/user" import user from "src/agent/user"

View File

@ -4,7 +4,7 @@
import Tabs from "src/partials/Tabs.svelte" import Tabs from "src/partials/Tabs.svelte"
import Content from "src/partials/Content.svelte" import Content from "src/partials/Content.svelte"
import MessagesListItem from "src/views/messages/MessagesListItem.svelte" import MessagesListItem from "src/views/messages/MessagesListItem.svelte"
import {watch} from "src/agent/table" import {watch} from "src/agent/storage"
let activeTab = "messages" let activeTab = "messages"

View File

@ -18,7 +18,7 @@
import user from "src/agent/user" import user from "src/agent/user"
import {sampleRelays, getPubkeyWriteRelays} from "src/agent/relays" import {sampleRelays, getPubkeyWriteRelays} from "src/agent/relays"
import network from "src/agent/network" import network from "src/agent/network"
import {getPersonWithFallback, people} from "src/agent/state" import {getPersonWithFallback, people} from "src/agent/tables"
import {routes, modal} from "src/app/ui" import {routes, modal} from "src/app/ui"
import PersonCircle from "src/partials/PersonCircle.svelte" import PersonCircle from "src/partials/PersonCircle.svelte"

View File

@ -7,7 +7,7 @@
import Content from "src/partials/Content.svelte" import Content from "src/partials/Content.svelte"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import Feed from "src/views/feed/Feed.svelte" import Feed from "src/views/feed/Feed.svelte"
import {relays} from "src/agent/state" import {relays} from "src/agent/tables"
import pool from "src/agent/pool" import pool from "src/agent/pool"
import user from "src/agent/user" import user from "src/agent/user"

View File

@ -1,10 +1,25 @@
import {bech32, utf8} from '@scure/base' import {bech32, utf8} from "@scure/base"
import {debounce, throttle} from 'throttle-debounce' import {debounce, throttle} from "throttle-debounce"
import {gt, aperture, path as getPath, allPass, pipe, isNil, complement, equals, is, pluck, sum, identity, sortBy} from "ramda" import {
gt,
mergeDeepRight,
aperture,
path as getPath,
allPass,
pipe,
isNil,
complement,
equals,
is,
pluck,
sum,
identity,
sortBy,
} from "ramda"
import Fuse from "fuse.js/dist/fuse.min.js" import Fuse from "fuse.js/dist/fuse.min.js"
import {writable} from 'svelte/store' import {writable} from "svelte/store"
import {isObject, round} from 'hurdak/lib/hurdak' import {isObject, round} from "hurdak/lib/hurdak"
import {warn} from 'src/util/logger' import {warn} from "src/util/logger"
export const fuzzy = (data, opts = {}) => { export const fuzzy = (data, opts = {}) => {
const fuse = new Fuse(data, opts) const fuse = new Fuse(data, opts)
@ -16,7 +31,7 @@ export const fuzzy = (data, opts = {}) => {
export const hash = s => export const hash = s =>
Math.abs(s.split("").reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0)) Math.abs(s.split("").reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0))
export const getLocalJson = (k) => { export const getLocalJson = k => {
try { try {
return JSON.parse(localStorage.getItem(k)) return JSON.parse(localStorage.getItem(k))
} catch (e) { } catch (e) {
@ -36,20 +51,29 @@ export const setLocalJson = (k, v) => {
export const now = () => Math.round(new Date().valueOf() / 1000) export const now = () => Math.round(new Date().valueOf() / 1000)
export const timedelta = (n, unit = 'seconds') => { export const timedelta = (n, unit = "seconds") => {
switch (unit) { switch (unit) {
case 'seconds': case 'second': return n case "seconds":
case 'minutes': case 'minute': return n * 60 case "second":
case 'hours': case 'hour': return n * 60 * 60 return n
case 'days': case 'day': return n * 60 * 60 * 24 case "minutes":
default: throw new Error(`Invalid unit ${unit}`) case "minute":
return n * 60
case "hours":
case "hour":
return n * 60 * 60
case "days":
case "day":
return n * 60 * 60 * 24
default:
throw new Error(`Invalid unit ${unit}`)
} }
} }
export const formatTimestamp = ts => { export const formatTimestamp = ts => {
const formatter = new Intl.DateTimeFormat('en-US', { const formatter = new Intl.DateTimeFormat("en-US", {
dateStyle: 'medium', dateStyle: "medium",
timeStyle: 'short', timeStyle: "short",
}) })
return formatter.format(new Date(ts * 1000)) return formatter.format(new Date(ts * 1000))
@ -58,21 +82,21 @@ export const formatTimestamp = ts => {
export const formatTimestampRelative = ts => { export const formatTimestampRelative = ts => {
let unit let unit
let delta = now() - ts let delta = now() - ts
if (delta < timedelta(1, 'minute')) { if (delta < timedelta(1, "minute")) {
unit = 'second' unit = "second"
} else if (delta < timedelta(1, 'hour')) { } else if (delta < timedelta(1, "hour")) {
unit = 'minute' unit = "minute"
delta = Math.round(delta / timedelta(1, 'minute')) delta = Math.round(delta / timedelta(1, "minute"))
} else if (delta < timedelta(2, 'day')) { } else if (delta < timedelta(2, "day")) {
unit = 'hour' unit = "hour"
delta = Math.round(delta / timedelta(1, 'hour')) delta = Math.round(delta / timedelta(1, "hour"))
} else { } else {
unit = 'day' unit = "day"
delta = Math.round(delta / timedelta(1, 'day')) delta = Math.round(delta / timedelta(1, "day"))
} }
const formatter = new Intl.RelativeTimeFormat('en-US', { const formatter = new Intl.RelativeTimeFormat("en-US", {
numeric: 'auto', numeric: "auto",
}) })
return formatter.format(-delta, unit as Intl.RelativeTimeFormatUnit) return formatter.format(-delta, unit as Intl.RelativeTimeFormatUnit)
@ -137,15 +161,6 @@ export const createScroller = (loadMore, {reverse = false} = {}) => {
export const randomChoice = xs => xs[Math.floor(Math.random() * xs.length)] export const randomChoice = xs => xs[Math.floor(Math.random() * xs.length)]
export const getLastSync = (k, fallback = 0) => {
const key = `${k}/lastSync`
const lastSync = getLocalJson(key) || fallback
setLocalJson(key, now())
return lastSync
}
export class Cursor { export class Cursor {
until: number until: number
limit: number limit: number
@ -179,17 +194,14 @@ export class Cursor {
// feed. Multiply it by the number of events we have but scale down to avoid // feed. Multiply it by the number of events we have but scale down to avoid
// blowing past big gaps due to misbehaving relays skewing the results. Trim off // blowing past big gaps due to misbehaving relays skewing the results. Trim off
// outliers and scale based on results/requests to help with that // outliers and scale based on results/requests to help with that
const timestamps = sortBy(identity, pluck('created_at', events)) const timestamps = sortBy(identity, pluck("created_at", events))
const gaps = aperture(2, timestamps).map(([a, b]) => b - a) const gaps = aperture(2, timestamps).map(([a, b]) => b - a)
const high = quantile(gaps, 0.5) const high = quantile(gaps, 0.5)
const gap = avg(gaps.filter(gt(high))) const gap = avg(gaps.filter(gt(high)))
// If we're just warming up, scale the window down even further to avoid // If we're just warming up, scale the window down even further to avoid
// blowing past the most relevant time period // blowing past the most relevant time period
const scale = ( const scale = Math.min(1, Math.log10(events.length)) * Math.min(1, Math.log10(this.count + 1))
Math.min(1, Math.log10(events.length))
* Math.min(1, Math.log10(this.count + 1))
)
// Only paginate part of the way so we can avoid missing stuff // Only paginate part of the way so we can avoid missing stuff
this.until -= Math.round(gap * scale * this.limit) this.until -= Math.round(gap * scale * this.limit)
@ -201,8 +213,8 @@ export const synced = (key, defaultValue = null) => {
// If it's an object, merge defaults // If it's an object, merge defaults
const store = writable( const store = writable(
isObject(defaultValue) isObject(defaultValue)
? {...defaultValue, ...getLocalJson(key)} ? mergeDeepRight(defaultValue, getLocalJson(key) || {})
: (getLocalJson(key) || defaultValue) : getLocalJson(key) || defaultValue
) )
store.subscribe(debounce(1000, $value => setLocalJson(key, $value))) store.subscribe(debounce(1000, $value => setLocalJson(key, $value)))
@ -210,7 +222,7 @@ export const synced = (key, defaultValue = null) => {
return store return store
} }
export const shuffle = sortBy(() => Math.random() > 0.5) export const shuffle = sortBy(() => Math.random() > 0.5)
export const batch = (t, f) => { export const batch = (t, f) => {
const xs = [] const xs = []
@ -236,42 +248,43 @@ export const avg = xs => sum(xs) / xs.length
export const where = filters => export const where = filters =>
allPass( allPass(
Object.entries(filters) Object.entries(filters).map(([key, value]) => {
.map(([key, value]) => { /* eslint prefer-const: 0 */
/* eslint prefer-const: 0 */ let [field, operator = "eq"] = key.split(":")
let [field, operator = 'eq'] = key.split(':') let test,
let test, modifier = identity, parts = field.split('.') modifier = identity,
parts = field.split(".")
if (operator.startsWith('!')) { if (operator.startsWith("!")) {
operator = operator.slice(1) operator = operator.slice(1)
modifier = complement modifier = complement
} }
if (operator === 'eq' && is(Array, value)) { if (operator === "eq" && is(Array, value)) {
test = v => (value as Array<any>).includes(v) test = v => (value as Array<any>).includes(v)
} else if (operator === 'eq') { } else if (operator === "eq") {
test = equals(value) test = equals(value)
} else if (operator === 'lt') { } else if (operator === "lt") {
test = v => (v || 0) < value test = v => (v || 0) < value
} else if (operator === 'lte') { } else if (operator === "lte") {
test = v => (v || 0) <= value test = v => (v || 0) <= value
} else if (operator === 'gt') { } else if (operator === "gt") {
test = v => (v || 0) > value test = v => (v || 0) > value
} else if (operator === 'gte') { } else if (operator === "gte") {
test = v => (v || 0) >= value test = v => (v || 0) >= value
} else if (operator === 'nil') { } else if (operator === "nil") {
test = isNil test = isNil
} else { } else {
throw new Error(`Invalid operator ${operator}`) throw new Error(`Invalid operator ${operator}`)
} }
return pipe(getPath(parts), modifier(test)) return pipe(getPath(parts), modifier(test))
}) })
) )
// https://stackoverflow.com/a/21682946 // https://stackoverflow.com/a/21682946
export const stringToHue = value => { export const stringToHue = value => {
let hash = 0; let hash = 0
for (let i = 0; i < value.length; i++) { for (let i = 0; i < value.length; i++) {
hash = value.charCodeAt(i) + ((hash << 5) - hash) hash = value.charCodeAt(i) + ((hash << 5) - hash)
hash = hash & hash hash = hash & hash
@ -303,14 +316,12 @@ export const tryFunc = (f, ignore = null) => {
} }
} }
export const tryJson = f => tryFunc(f, 'JSON') export const tryJson = f => tryFunc(f, "JSON")
export const tryFetch = f => tryFunc(f, 'fetch') export const tryFetch = f => tryFunc(f, "fetch")
export const union = (...sets) => export const union = (...sets) => new Set(sets.flatMap(s => Array.from(s)))
new Set(sets.flatMap(s => Array.from(s)))
export const difference = (a, b) => export const difference = (a, b) => new Set(Array.from(a).filter(x => !b.has(x)))
new Set(Array.from(a).filter(x => !b.has(x)))
export const quantile = (a, q) => { export const quantile = (a, q) => {
const sorted = sortBy(identity, a) const sorted = sortBy(identity, a)
@ -334,7 +345,7 @@ export const fetchJson = async (url, opts: FetchOpts = {}) => {
opts.headers = {} opts.headers = {}
} }
opts.headers['Accept'] = 'application/json' opts.headers["Accept"] = "application/json"
const res = await fetch(url, opts as RequestInit) const res = await fetch(url, opts as RequestInit)
const json = await res.json() const json = await res.json()
@ -344,14 +355,14 @@ export const fetchJson = async (url, opts: FetchOpts = {}) => {
export const postJson = async (url, data, opts: FetchOpts = {}) => { export const postJson = async (url, data, opts: FetchOpts = {}) => {
if (!opts.method) { if (!opts.method) {
opts.method = 'POST' opts.method = "POST"
} }
if (!opts.headers) { if (!opts.headers) {
opts.headers = {} opts.headers = {}
} }
opts.headers['Content-Type'] = 'application/json' opts.headers["Content-Type"] = "application/json"
opts.body = JSON.stringify(data) opts.body = JSON.stringify(data)
return fetchJson(url, opts) return fetchJson(url, opts)
@ -362,20 +373,19 @@ export const uploadFile = (url, fileObj) => {
body.append("file", fileObj) body.append("file", fileObj)
return fetchJson(url, {method: 'POST', body}) return fetchJson(url, {method: "POST", body})
} }
export const lnurlEncode = (prefix, url) => export const lnurlEncode = (prefix, url) =>
bech32.encode(prefix, bech32.toWords(utf8.decode(url)), false) bech32.encode(prefix, bech32.toWords(utf8.decode(url)), false)
export const lnurlDecode = b32 => export const lnurlDecode = b32 => utf8.encode(bech32.fromWords(bech32.decode(b32, false).words))
utf8.encode(bech32.fromWords(bech32.decode(b32, false).words))
export const formatSats = sats => { export const formatSats = sats => {
const formatter = new Intl.NumberFormat() const formatter = new Intl.NumberFormat()
if (sats < 1_000) return formatter.format(sats) if (sats < 1_000) return formatter.format(sats)
if (sats < 1_000_000) return formatter.format(round(1, sats / 1000)) + 'K' if (sats < 1_000_000) return formatter.format(round(1, sats / 1000)) + "K"
if (sats < 100_000_000) return formatter.format(round(1, sats / 1_000_000)) + 'MM' if (sats < 100_000_000) return formatter.format(round(1, sats / 1_000_000)) + "MM"
return formatter.format(round(2, sats / 100_000_000)) + 'BTC' return formatter.format(round(2, sats / 100_000_000)) + "BTC"
} }

View File

@ -1,9 +1,10 @@
import type {DisplayEvent} from 'src/util/types' import type {DisplayEvent} from "src/util/types"
import {is, fromPairs, mergeLeft, last, identity, objOf, prop, flatten, uniq} from 'ramda' import {is, fromPairs, mergeLeft, last, identity, objOf, prop, flatten, uniq} from "ramda"
import {nip19} from 'nostr-tools' import {nip19} from "nostr-tools"
import {ensurePlural, ellipsize, first} from 'hurdak/lib/hurdak' import {ensurePlural, ellipsize, first} from "hurdak/lib/hurdak"
export const personKinds = [0, 2, 3, 10000, 10001, 10002, 12165] export const personKinds = [0, 2, 3, 10001, 10002]
export const userKinds = personKinds.concat([10000])
export class Tags { export class Tags {
tags: Array<any> tags: Array<any>
@ -11,7 +12,7 @@ export class Tags {
this.tags = tags this.tags = tags
} }
static from(events) { static from(events) {
return new Tags(ensurePlural(events).flatMap(prop('tags'))) return new Tags(ensurePlural(events).flatMap(prop("tags")))
} }
static wrap(tags) { static wrap(tags) {
return new Tags((tags || []).filter(identity)) return new Tags((tags || []).filter(identity))
@ -26,7 +27,7 @@ export class Tags {
return last(this.tags) return last(this.tags)
} }
relays() { relays() {
return uniq(flatten(this.tags).filter(isShareableRelay)).map(objOf('url')) return uniq(flatten(this.tags).filter(isShareableRelay)).map(objOf("url"))
} }
pubkeys() { pubkeys() {
return this.type("p").values().all() return this.type("p").values().all()
@ -55,12 +56,17 @@ export class Tags {
export const findReply = e => export const findReply = e =>
Tags.from(e).type("e").mark("reply").first() || Tags.from(e).type("e").last() Tags.from(e).type("e").mark("reply").first() || Tags.from(e).type("e").last()
export const findReplyId = e => Tags.wrap([findReply(e)]).values().first() export const findReplyId = e =>
Tags.wrap([findReply(e)])
.values()
.first()
export const findRoot = e => export const findRoot = e => Tags.from(e).type("e").mark("root").first()
Tags.from(e).type("e").mark("root").first()
export const findRootId = e => Tags.wrap([findRoot(e)]).values().first() export const findRootId = e =>
Tags.wrap([findRoot(e)])
.values()
.first()
export const displayPerson = p => { export const displayPerson = p => {
if (p.kind0?.display_name) { if (p.kind0?.display_name) {
@ -76,13 +82,13 @@ export const displayPerson = p => {
} catch (e) { } catch (e) {
console.error(e) console.error(e)
return '' return ""
} }
} }
export const displayRelay = ({url}) => last(url.split('://')) export const displayRelay = ({url}) => last(url.split("://"))
export const isLike = content => ['', '+', '🤙', '👍', '❤️', '😎', '🏅'].includes(content) export const isLike = content => ["", "+", "🤙", "👍", "❤️", "😎", "🏅"].includes(content)
export const isAlert = (e, pubkey) => { export const isAlert = (e, pubkey) => {
if (![1, 7, 9735].includes(e.kind)) { if (![1, 7, 9735].includes(e.kind)) {
@ -102,28 +108,26 @@ export const isAlert = (e, pubkey) => {
return true return true
} }
export const isRelay = url => ( export const isRelay = url =>
typeof url === 'string' typeof url === "string" &&
// It should have the protocol included // It should have the protocol included
&& url.match(/^wss?:\/\/.+/) url.match(/^wss?:\/\/.+/)
)
export const isShareableRelay = url => ( export const isShareableRelay = url =>
isRelay(url) isRelay(url) &&
// Don't match stuff with a port number // Don't match stuff with a port number
&& !url.slice(6).match(/:\d+/) !url.slice(6).match(/:\d+/) &&
// Don't match raw ip addresses // Don't match raw ip addresses
&& !url.slice(6).match(/\d+\.\d+\.\d+\.\d+/) !url.slice(6).match(/\d+\.\d+\.\d+\.\d+/) &&
// Skip nostr.wine's virtual relays // Skip nostr.wine's virtual relays
&& !url.slice(6).match(/\/npub/) !url.slice(6).match(/\/npub/)
)
export const normalizeRelayUrl = url => url.replace(/\/+$/, '').toLowerCase().trim() export const normalizeRelayUrl = url => url.replace(/\/+$/, "").toLowerCase().trim()
export const roomAttrs = ['name', 'about', 'picture'] export const roomAttrs = ["name", "about", "picture"]
export const asDisplayEvent = event => export const asDisplayEvent = event =>
({replies: [], reactions: [], zaps: [], ...event}) as DisplayEvent ({replies: [], reactions: [], zaps: [], ...event} as DisplayEvent)
export const toHex = (data: string): string | null => { export const toHex = (data: string): string | null => {
try { try {

View File

@ -6,7 +6,7 @@
import RelaySearch from "src/views/relays/RelaySearch.svelte" import RelaySearch from "src/views/relays/RelaySearch.svelte"
import RelayCard from "src/views/relays/RelayCard.svelte" import RelayCard from "src/views/relays/RelayCard.svelte"
import PersonSearch from "src/views/person/PersonSearch.svelte" import PersonSearch from "src/views/person/PersonSearch.svelte"
import {getPersonWithFallback} from "src/agent/state" import {getPersonWithFallback} from "src/agent/tables"
import user from "src/agent/user" import user from "src/agent/user"
export let enforceRelays = true export let enforceRelays = true

View File

@ -21,7 +21,7 @@
const submit = async event => { const submit = async event => {
event.preventDefault() event.preventDefault()
user.settings.set(values) user.profile.update($p => ({...$p, settings: values}))
toast.show("info", "Your settings have been saved!") toast.show("info", "Your settings have been saved!")
} }

View File

@ -5,7 +5,7 @@
import {menuIsOpen, installPrompt, routes} from "src/app/ui" import {menuIsOpen, installPrompt, routes} from "src/app/ui"
import {newAlerts, newDirectMessages, newChatMessages} from "src/app/alerts" import {newAlerts, newDirectMessages, newChatMessages} from "src/app/alerts"
import {slowConnections} from "src/app/connection" import {slowConnections} from "src/app/connection"
import PersonCircle from "src/partials/PersonCircle.svelte"; import PersonCircle from "src/partials/PersonCircle.svelte"
const {profile, canPublish} = user const {profile, canPublish} = user
@ -28,7 +28,7 @@
class="fixed top-0 bottom-0 left-0 z-20 mt-16 w-56 overflow-hidden border-r border-medium bg-dark pt-4 class="fixed top-0 bottom-0 left-0 z-20 mt-16 w-56 overflow-hidden border-r border-medium bg-dark pt-4
pb-20 text-white shadow-xl transition-all lg:mt-0 lg:ml-0" pb-20 text-white shadow-xl transition-all lg:mt-0 lg:ml-0"
class:-ml-56={!$menuIsOpen}> class:-ml-56={!$menuIsOpen}>
{#if $profile} {#if $profile.pubkey}
<li> <li>
<a href={routes.person($profile.pubkey)} class="flex items-center gap-2 px-4 py-2 pb-6"> <a href={routes.person($profile.pubkey)} class="flex items-center gap-2 px-4 py-2 pb-6">
<PersonCircle size={6} person={$profile} /> <PersonCircle size={6} person={$profile} />

View File

@ -3,7 +3,7 @@
import Badge from "src/partials/Badge.svelte" import Badge from "src/partials/Badge.svelte"
import Popover from "src/partials/Popover.svelte" import Popover from "src/partials/Popover.svelte"
import {formatTimestamp} from "src/util/misc" import {formatTimestamp} from "src/util/misc"
import {getPersonWithFallback} from "src/agent/state" import {getPersonWithFallback} from "src/agent/tables"
import {modal} from "src/app/ui" import {modal} from "src/app/ui"
export let note export let note

View File

@ -4,7 +4,7 @@
import {displayPerson} from "src/util/nostr" import {displayPerson} from "src/util/nostr"
import Popover from "src/partials/Popover.svelte" import Popover from "src/partials/Popover.svelte"
import PersonSummary from "src/views/person/PersonSummary.svelte" import PersonSummary from "src/views/person/PersonSummary.svelte"
import {getPersonWithFallback} from "src/agent/state" import {getPersonWithFallback} from "src/agent/tables"
import {modal} from "src/app/ui" import {modal} from "src/app/ui"
import PersonCircle from "src/partials/PersonCircle.svelte" import PersonCircle from "src/partials/PersonCircle.svelte"

View File

@ -8,7 +8,7 @@
import Textarea from "src/partials/Textarea.svelte" import Textarea from "src/partials/Textarea.svelte"
import Button from "src/partials/Button.svelte" import Button from "src/partials/Button.svelte"
import {getUserWriteRelays} from "src/agent/relays" import {getUserWriteRelays} from "src/agent/relays"
import {rooms} from "src/agent/state" import {rooms} from "src/agent/tables"
import cmd from "src/agent/cmd" import cmd from "src/agent/cmd"
import {toast, modal} from "src/app/ui" import {toast, modal} from "src/app/ui"
import {publishWithToast} from "src/app" import {publishWithToast} from "src/app"

View File

@ -4,7 +4,7 @@
import {fly} from "svelte/transition" import {fly} from "svelte/transition"
import {ellipsize} from "hurdak/lib/hurdak" import {ellipsize} from "hurdak/lib/hurdak"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import {rooms} from "src/agent/state" import {rooms} from "src/agent/tables"
export let room export let room

View File

@ -4,7 +4,7 @@
import {onDestroy, onMount} from "svelte" import {onDestroy, onMount} from "svelte"
import {navigate} from "svelte-routing" import {navigate} from "svelte-routing"
import {sleep, shuffle} from "src/util/misc" import {sleep, shuffle} from "src/util/misc"
import {isRelay} from "src/util/nostr" import {isRelay, userKinds} from "src/util/nostr"
import Content from "src/partials/Content.svelte" import Content from "src/partials/Content.svelte"
import Spinner from "src/partials/Spinner.svelte" import Spinner from "src/partials/Spinner.svelte"
import Input from "src/partials/Input.svelte" import Input from "src/partials/Input.svelte"
@ -12,7 +12,7 @@
import RelayCardSimple from "src/partials/RelayCardSimple.svelte" import RelayCardSimple from "src/partials/RelayCardSimple.svelte"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import Modal from "src/partials/Modal.svelte" import Modal from "src/partials/Modal.svelte"
import {watch} from "src/agent/table" import {watch} from "src/agent/storage"
import network from "src/agent/network" import network from "src/agent/network"
import user from "src/agent/user" import user from "src/agent/user"
import {loadAppData} from "src/app" import {loadAppData} from "src/app"
@ -48,23 +48,25 @@
attemptedRelays.add(relay.url) attemptedRelays.add(relay.url)
currentRelays[i] = relay currentRelays[i] = relay
network.loadPeople([user.getPubkey()], {relays: [relay], force: true}).then(async () => { network
// Wait a bit before removing the relay to smooth out the ui .loadPeople([user.getPubkey()], {relays: [relay], force: true, kinds: userKinds})
await sleep(1000) .then(async () => {
// Wait a bit before removing the relay to smooth out the ui
await sleep(1000)
currentRelays[i] = null currentRelays[i] = null
// Make sure we have relays and follows before calling it good. This helps us avoid // Make sure we have relays and follows before calling it good. This helps us avoid
// nuking follow lists later on // nuking follow lists later on
if (searching && user.getRelays().length > 0 && user.getPetnames().length > 0) { if (searching && user.getRelays().length > 0 && user.getPetnames().length > 0) {
searching = false searching = false
modal = "success" modal = "success"
await Promise.all([loadAppData(user.getPubkey()), sleep(3000)]) await Promise.all([loadAppData(user.getPubkey()), sleep(3000)])
navigate("/notes/follows") navigate("/notes/follows")
} }
}) })
} }
if (all(isNil, Object.values(currentRelays)) && isNil(customRelayUrl)) { if (all(isNil, Object.values(currentRelays)) && isNil(customRelayUrl)) {

View File

@ -4,7 +4,7 @@
import {fly} from "svelte/transition" import {fly} from "svelte/transition"
import {ellipsize} from "hurdak/lib/hurdak" import {ellipsize} from "hurdak/lib/hurdak"
import {displayPerson} from "src/util/nostr" import {displayPerson} from "src/util/nostr"
import {getPersonWithFallback} from "src/agent/state" import {getPersonWithFallback} from "src/agent/tables"
import {lastChecked} from "src/app/alerts" import {lastChecked} from "src/app/alerts"
import PersonCircle from "src/partials/PersonCircle.svelte" import PersonCircle from "src/partials/PersonCircle.svelte"

View File

@ -29,8 +29,8 @@
import keys from "src/agent/keys" import keys from "src/agent/keys"
import network from "src/agent/network" import network from "src/agent/network"
import {getEventPublishRelays, getRelaysForEventParent} from "src/agent/relays" import {getEventPublishRelays, getRelaysForEventParent} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/state" import {getPersonWithFallback} from "src/agent/tables"
import {watch} from "src/agent/table" import {watch} from "src/agent/storage"
import cmd from "src/agent/cmd" import cmd from "src/agent/cmd"
import {routes} from "src/app/ui" import {routes} from "src/app/ui"
import {publishWithToast} from "src/app" import {publishWithToast} from "src/app"

View File

@ -16,8 +16,8 @@
import Modal from "src/partials/Modal.svelte" import Modal from "src/partials/Modal.svelte"
import Heading from "src/partials/Heading.svelte" import Heading from "src/partials/Heading.svelte"
import {getUserWriteRelays} from "src/agent/relays" import {getUserWriteRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/state" import {getPersonWithFallback} from "src/agent/tables"
import {watch} from "src/agent/table" import {watch} from "src/agent/storage"
import cmd from "src/agent/cmd" import cmd from "src/agent/cmd"
import {toast, modal} from "src/app/ui" import {toast, modal} from "src/app/ui"
import {publishWithToast} from "src/app" import {publishWithToast} from "src/app"

View File

@ -13,7 +13,7 @@
import OnboardingComplete from "src/views/onboarding/OnboardingComplete.svelte" import OnboardingComplete from "src/views/onboarding/OnboardingComplete.svelte"
import {getFollows} from "src/agent/social" import {getFollows} from "src/agent/social"
import {getPubkeyWriteRelays, sampleRelays} from "src/agent/relays" import {getPubkeyWriteRelays, sampleRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/state" import {getPersonWithFallback} from "src/agent/tables"
import network from "src/agent/network" import network from "src/agent/network"
import user from "src/agent/user" import user from "src/agent/user"
import keys from "src/agent/keys" import keys from "src/agent/keys"

View File

@ -6,8 +6,8 @@
import Heading from "src/partials/Heading.svelte" import Heading from "src/partials/Heading.svelte"
import Content from "src/partials/Content.svelte" import Content from "src/partials/Content.svelte"
import PersonInfo from "src/partials/PersonInfo.svelte" import PersonInfo from "src/partials/PersonInfo.svelte"
import {getPersonWithFallback} from "src/agent/state" import {getPersonWithFallback} from "src/agent/tables"
import {watch} from "src/agent/table" import {watch} from "src/agent/storage"
import {modal} from "src/app/ui" import {modal} from "src/app/ui"
export let follows export let follows

View File

@ -6,7 +6,7 @@
import Heading from "src/partials/Heading.svelte" import Heading from "src/partials/Heading.svelte"
import Content from "src/partials/Content.svelte" import Content from "src/partials/Content.svelte"
import RelayCard from "src/partials/RelayCard.svelte" import RelayCard from "src/partials/RelayCard.svelte"
import {watch} from "src/agent/table" import {watch} from "src/agent/storage"
import {modal} from "src/app/ui" import {modal} from "src/app/ui"
export let relays export let relays

View File

@ -1,8 +1,8 @@
<script type="ts"> <script type="ts">
import Content from "src/partials/Content.svelte" import Content from "src/partials/Content.svelte"
import PersonInfo from "src/views/person/PersonInfo.svelte" import PersonInfo from "src/views/person/PersonInfo.svelte"
import {getPersonWithFallback} from "src/agent/state" import {getPersonWithFallback} from "src/agent/tables"
import {watch} from "src/agent/table" import {watch} from "src/agent/storage"
import network from "src/agent/network" import network from "src/agent/network"
export let pubkeys export let pubkeys

View File

@ -5,7 +5,7 @@
import Spinner from "src/partials/Spinner.svelte" import Spinner from "src/partials/Spinner.svelte"
import PersonInfo from "src/views/person/PersonInfo.svelte" import PersonInfo from "src/views/person/PersonInfo.svelte"
import {getUserReadRelays} from "src/agent/relays" import {getUserReadRelays} from "src/agent/relays"
import {watch} from "src/agent/table" import {watch} from "src/agent/storage"
import network from "src/agent/network" import network from "src/agent/network"
import user from "src/agent/user" import user from "src/agent/user"

View File

@ -6,8 +6,8 @@
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import user from "src/agent/user" import user from "src/agent/user"
import {sampleRelays, getPubkeyWriteRelays} from "src/agent/relays" import {sampleRelays, getPubkeyWriteRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/state" import {getPersonWithFallback} from "src/agent/tables"
import {watch} from "src/agent/table" import {watch} from "src/agent/storage"
import {routes} from "src/app/ui" import {routes} from "src/app/ui"
import PersonCircle from "src/partials/PersonCircle.svelte" import PersonCircle from "src/partials/PersonCircle.svelte"

View File

@ -3,7 +3,7 @@
import {fuzzy} from "src/util/misc" import {fuzzy} from "src/util/misc"
import Input from "src/partials/Input.svelte" import Input from "src/partials/Input.svelte"
import RelayCard from "src/views/relays/RelayCard.svelte" import RelayCard from "src/views/relays/RelayCard.svelte"
import {watch} from "src/agent/table" import {watch} from "src/agent/storage"
import user from "src/agent/user" import user from "src/agent/user"
let q = "" let q = ""