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

View File

@ -9,9 +9,9 @@
"preview": "vite preview",
"check:es": "eslint src/*/** --quiet",
"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:*",
"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"
},
"devDependencies": {

View File

@ -14,12 +14,13 @@
import {timedelta, shuffle, now, sleep} from "src/util/misc"
import {displayPerson, isLike} from "src/util/nostr"
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 network from "src/agent/network"
import pool from "src/agent/pool"
import {getUserRelays, initializeRelayList} from "src/agent/relays"
import sync from "src/agent/sync"
import * as tables from "src/agent/tables"
import user from "src/agent/user"
import {loadAppData} from "src/app"
import alerts from "src/app/alerts"
@ -64,7 +65,7 @@
import AddRelay from "src/views/relays/AddRelay.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 = ""

View File

@ -1,14 +1,13 @@
import {pick, last, prop, uniqBy} from 'ramda'
import {get} from 'svelte/store'
import {roomAttrs, displayPerson, findReplyId, findRootId} from 'src/util/nostr'
import {getPubkeyWriteRelays, getRelayForPersonHint, sampleRelays} from 'src/agent/relays'
import {getPersonWithFallback} from 'src/agent/state'
import pool from 'src/agent/pool'
import sync from 'src/agent/sync'
import keys from 'src/agent/keys'
import {pick, last, prop, uniqBy} from "ramda"
import {get} from "svelte/store"
import {roomAttrs, displayPerson, findReplyId, findRootId} from "src/util/nostr"
import {getPubkeyWriteRelays, getRelayForPersonHint, sampleRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/tables"
import pool from "src/agent/pool"
import sync from "src/agent/sync"
import keys from "src/agent/keys"
const updateUser = updates =>
new PublishableEvent(0, {content: JSON.stringify(updates)})
const updateUser = updates => new PublishableEvent(0, {content: JSON.stringify(updates)})
const setRelays = newRelays =>
new PublishableEvent(10002, {
@ -16,18 +15,16 @@ const setRelays = newRelays =>
const t = ["r", r.url]
if (!r.write) {
t.push('read')
t.push("read")
}
return t
}),
})
const setPetnames = petnames =>
new PublishableEvent(3, {tags: petnames})
const setPetnames = petnames => new PublishableEvent(3, {tags: petnames})
const setMutes = mutes =>
new PublishableEvent(10000, {tags: mutes})
const setMutes = mutes => new PublishableEvent(10000, {tags: mutes})
const createRoom = room =>
new PublishableEvent(40, {content: JSON.stringify(pick(roomAttrs, room))})
@ -58,7 +55,11 @@ const getReplyTags = n => {
const {url} = getRelayForPersonHint(n.pubkey, n)
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 = []) => {
@ -66,21 +67,20 @@ const tagsFromParent = (n, newTags = []) => {
return uniqBy(
// 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
newTags
// Add standard reply tags
.concat(getReplyTags(n))
// Inherit p and e tags, but remove marks and self-mentions
.concat(
n.tags
.filter(t => {
if (t[1] === pubkey) return false
if (!["p", "e"].includes(t[0])) return false
if (['reply', 'root'].includes(last(t))) return false
n.tags.filter(t => {
if (t[1] === pubkey) return false
if (!["p", "e"].includes(t[0])) 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(
note,
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]))
)
@ -115,14 +115,13 @@ const requestZap = (relays, content, pubkey, eventId, amount, lnurl) => {
return new PublishableEvent(9734, {content, tags})
}
const deleteEvent = ids =>
new PublishableEvent(5, {tags: ids.map(id => ["e", id])})
const deleteEvent = ids => new PublishableEvent(5, {tags: ids.map(id => ["e", id])})
// Utils
class PublishableEvent {
event: Record<string, any>
constructor(kind, {content = '', tags = []}) {
constructor(kind, {content = "", tags = []}) {
const pubkey = get(keys.pubkey)
const createdAt = Math.round(new Date().valueOf() / 1000)
@ -139,7 +138,18 @@ class PublishableEvent {
}
export default {
updateUser, setRelays, setPetnames, setMutes, createRoom, updateRoom,
createChatMessage, createDirectMessage, createNote, createReaction,
createReply, requestZap, deleteEvent, PublishableEvent,
updateUser,
setRelays,
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 {sortBy, assoc, uniq, uniqBy, prop, propEq, reject, groupBy, pluck} from 'ramda'
import {personKinds, findReplyId} from 'src/util/nostr'
import {log} from 'src/util/logger'
import {chunk} from 'hurdak/lib/hurdak'
import {batch, now, timedelta} from 'src/util/misc'
import type {MyEvent} from "src/util/types"
import {sortBy, assoc, uniq, uniqBy, prop, propEq, reject, groupBy, pluck} from "ramda"
import {personKinds, findReplyId} from "src/util/nostr"
import {log} from "src/util/logger"
import {chunk} from "hurdak/lib/hurdak"
import {batch, now, timedelta} from "src/util/misc"
import {
getRelaysForEventParent, getAllPubkeyWriteRelays, aggregateScores,
getRelaysForEventChildren, sampleRelays,
} from 'src/agent/relays'
import {people} from 'src/agent/state'
import pool from 'src/agent/pool'
import sync from 'src/agent/sync'
getRelaysForEventParent,
getAllPubkeyWriteRelays,
aggregateScores,
getRelaysForEventChildren,
sampleRelays,
} 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 => {
// If we're not reloading, only get pubkeys we don't already know about
return uniq(pubkeys).filter(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
while (events.length > 0 && depth > 0) {
const chunk = events.splice(0)
const authors = getStalePubkeys(pluck('pubkey', chunk))
const filter = [{kinds: [1, 7, 9735], '#e': pluck('id', chunk)}] as Array<object>
const authors = getStalePubkeys(pluck("pubkey", chunk))
const filter = [{kinds: [1, 7, 9735], "#e": pluck("id", chunk)}] as Array<object>
const relays = sampleRelays(aggregateScores(chunk.map(getRelaysForEventChildren)))
// Load authors and reactions in one subscription
@ -169,11 +172,11 @@ const streamContext = ({notes, onChunk, depth = 0}) =>
)
const applyContext = (notes, context) => {
context = context.map(assoc('isContext', true))
context = context.map(assoc("isContext", true))
const replies = context.filter(propEq('kind', 1))
const reactions = context.filter(propEq('kind', 7))
const zaps = context.filter(propEq('kind', 9735))
const replies = context.filter(propEq("kind", 1))
const reactions = context.filter(propEq("kind", 7))
const zaps = context.filter(propEq("kind", 9735))
const repliesByParentId = groupBy(findReplyId, replies)
const reactionsByParentId = groupBy(findReplyId, reactions)
@ -186,9 +189,9 @@ const applyContext = (notes, context) => {
return {
...note,
replies: sortBy(e => -e.created_at, uniqBy(prop('id'), combinedReplies).map(annotate)),
reactions: uniqBy(prop('id'), combinedReactions),
zaps: uniqBy(prop('id'), combinedZaps),
replies: sortBy(e => -e.created_at, uniqBy(prop("id"), combinedReplies).map(annotate)),
reactions: uniqBy(prop("id"), combinedReactions),
zaps: uniqBy(prop("id"), combinedZaps),
}
}
@ -196,5 +199,10 @@ const applyContext = (notes, context) => {
}
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 LRUCache from 'lru-cache'
import {warn} from 'src/util/logger'
import {filter, pipe, pick, groupBy, objOf, map, assoc, sortBy, uniqBy, prop} from 'ramda'
import {first} from 'hurdak/lib/hurdak'
import {Tags, isRelay, findReplyId} from 'src/util/nostr'
import {shuffle, fetchJson} from 'src/util/misc'
import {relays, routes} from 'src/agent/state'
import pool from 'src/agent/pool'
import user from 'src/agent/user'
import type {Relay} from "src/util/types"
import LRUCache from "lru-cache"
import {warn} from "src/util/logger"
import {filter, pipe, pick, groupBy, objOf, map, assoc, sortBy, uniqBy, prop} from "ramda"
import {first} from "hurdak/lib/hurdak"
import {Tags, isRelay, findReplyId} from "src/util/nostr"
import {shuffle, fetchJson} from "src/util/misc"
import {relays, routes} from "src/agent/tables"
import pool from "src/agent/pool"
import user from "src/agent/user"
// From Mike Dilger:
// 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 () => {
// Throw some hardcoded defaults in there
await relays.bulkPatch([
{url: 'wss://brb.io'},
{url: 'wss://nostr.zebedee.cloud'},
{url: 'wss://nostr-pub.wellorder.net'},
{url: 'wss://relay.nostr.band'},
{url: 'wss://nostr.pleb.network'},
{url: 'wss://relay.nostrich.de'},
{url: 'wss://relay.damus.io'},
{url: "wss://brb.io"},
{url: "wss://nostr.zebedee.cloud"},
{url: "wss://nostr-pub.wellorder.net"},
{url: "wss://relay.nostr.band"},
{url: "wss://nostr.pleb.network"},
{url: "wss://relay.nostrich.de"},
{url: "wss://relay.damus.io"},
])
// Load relays from nostr.watch via dufflepud
try {
const url = import.meta.env.VITE_DUFFLEPUD_URL + '/relay'
const url = import.meta.env.VITE_DUFFLEPUD_URL + "/relay"
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) {
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) => {
const filter = mode ? {pubkey, mode} : {pubkey}
const key = [mode, pubkey].join(':')
const key = [mode, pubkey].join(":")
let result = routesOverride || _getPubkeyRelaysCache.get(key)
if (!result) {
result = routes.all(filter)
_getPubkeyRelaysCache.set(key, result)
result = routes.all(filter)
_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
export const getAllPubkeyRelays = (pubkeys, mode = null) => {
// As an optimization, filter the database once and group by pubkey
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(
pubkeys.map(
pubkey => getPubkeyRelays(pubkey, mode, routesByPubkey[pubkey] || [])
)
pubkeys.map(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
export const getUserRelays = () =>
user.getRelays().map(assoc('score', 1))
export const getUserRelays = () => user.getRelays().map(assoc("score", 1))
export const getUserReadRelays = () =>
getUserRelays().filter(prop('read')).map(pick(['url', 'score']))
getUserRelays()
.filter(prop("read"))
.map(pick(["url", "score"]))
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
@ -113,12 +114,10 @@ export const getRelaysForEventParent = event => {
// 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.
export const getRelaysForEventChildren = event => {
return uniqByUrl(getPubkeyReadRelays(event.pubkey)
.concat({url: event.seen_on, score: 1}))
return uniqByUrl(getPubkeyReadRelays(event.pubkey).concat({url: event.seen_on, score: 1}))
}
export const getRelayForEventHint = event =>
({url: event.seen_on, score: 1})
export const getRelayForEventHint = event => ({url: event.seen_on, score: 1})
export const getRelayForPersonHint = (pubkey, event) =>
first(getPubkeyWriteRelays(pubkey)) || getRelayForEventHint(event)
@ -135,15 +134,14 @@ export const getEventPublishRelays = event => {
return uniqByUrl(aggregateScores(relayChunks).concat(getUserWriteRelays()))
}
// 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 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
// 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 (relays.length < limit) {
relays = relays.concat(
shuffle(getUserReadRelays()).slice(0, limit - relays.length)
)
relays = relays.concat(shuffle(getUserReadRelays()).slice(0, limit - relays.length))
}
return uniqByUrl(relays)
}
export const aggregateScores = relayGroups => {
const scores = {} as Record<string, {
score: number,
count: number,
weight?: number,
weightedScore?: number
}>
const scores = {} as Record<
string,
{
score: number
count: number
weight?: number
weightedScore?: number
}
>
for (const relays of relayGroups) {
for (const relay of relays) {
@ -195,7 +194,6 @@ export const aggregateScores = relayGroups => {
}
return sortByScore(
Object.entries(scores)
.map(([url, {weightedScore}]) => ({url, score: weightedScore}))
Object.entries(scores).map(([url, {weightedScore}]) => ({url, score: weightedScore}))
)
}

View File

@ -1,7 +1,7 @@
import {uniq, without} from 'ramda'
import {Tags} from 'src/util/nostr'
import {getPersonWithFallback} from 'src/agent/state'
import user from 'src/agent/user'
import {uniq, without} from "ramda"
import {Tags} from "src/util/nostr"
import {getPersonWithFallback} from "src/agent/tables"
import user from "src/agent/user"
export const getFollows = pubkey =>
Tags.wrap(getPersonWithFallback(pubkey).petnames).type("p").values().all()
@ -12,8 +12,7 @@ export const getNetwork = pubkey => {
return uniq(without(follows, follows.flatMap(getFollows)))
}
export const getUserFollows = (): Array<string> =>
Tags.wrap(user.getPetnames()).values().all()
export const getUserFollows = (): Array<string> => Tags.wrap(user.getPetnames()).values().all()
export const getUserNetwork = () => {
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 = {
topic: string
payload: object
}
// Plumbing
const worker = new Worker(new URL("./workers/database.js", import.meta.url), {type: "module"})
const worker = new Worker(
new URL('./workers/database.js', import.meta.url),
{type: 'module'}
)
worker.addEventListener('error', error)
worker.addEventListener("error", error)
class Channel {
id: string
@ -27,10 +30,10 @@ class Channel {
}
}
worker.addEventListener('message', this.onMessage)
worker.addEventListener("message", this.onMessage)
}
close() {
worker.removeEventListener('message', this.onMessage)
worker.removeEventListener("message", this.onMessage)
}
send(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 message = await call('localforage.call', {method, args})
const lf = async (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}`)
}
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,
hash,
} from "src/util/misc"
import {
Tags,
roomAttrs,
isRelay,
isShareableRelay,
normalizeRelayUrl,
} from "src/util/nostr"
import {getPersonWithFallback, people, relays, rooms, routes} from "src/agent/state"
import {Tags, roomAttrs, isRelay, isShareableRelay, normalizeRelayUrl} from "src/util/nostr"
import {people, userEvents, relays, rooms, routes} from "src/agent/tables"
import {uniqByUrl} from "src/agent/relays"
import user from "src/agent/user"
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 userPubkey = user.getPubkey()
const chunks = chunk(100, ensurePlural(events))
for (let i = 0; i < chunks.length; i++) {
for (const event of chunks[i]) {
if (event.pubkey === userPubkey) {
userEvents.put(event)
}
for (const handler of handlers[event.kind] || []) {
handler(event)
}
@ -45,12 +48,19 @@ const processEvents = async events => {
// 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) =>
nip05.queryProfile(as).then(result => {
if (result?.pubkey === pubkey) {
const person = getPersonWithFallback(pubkey)
people.patch({...person, verified_as: as})
updatePerson(pubkey, {verified_as: as})
if (result.relays?.length > 0) {
const urls = result.relays.filter(isRelay)
@ -92,7 +102,7 @@ const verifyZapper = async (pubkey, address) => {
const lnurl = lnurlEncode("lnurl", url)
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())
}
people.patch({
pubkey: e.pubkey,
updated_at: now(),
updatePerson(e.pubkey, {
kind0: {...person?.kind0, ...kind0},
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 => {
const person = people.get(e.pubkey)
if (e.created_at > (person?.petnames_updated_at || 0)) {
people.patch({
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?.petnames_updated_at) {
return
}
if (e.created_at > (person.relays_updated_at || 0)) {
tryJson(() => {
people.patch({
pubkey: e.pubkey,
relays_updated_at: e.created_at,
relays: Object.entries(JSON.parse(e.content))
updatePerson(e.pubkey, {
petnames_updated_at: e.created_at,
petnames: e.tags.filter(t => t[0] === "p"),
})
})
// 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]) => {
const {write, read} = conditions as Record<string, boolean | string>
@ -167,62 +188,36 @@ addHandler(3, e => {
read: [false, "!"].includes(read) ? false : true,
}
})
.filter(r => isRelay(r.url)),
})
})
}
})
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,
.filter(r => isRelay(r.url))
}) || p.relays
)
})
})
)
addHandler(
10000,
profileHandler("mutes", (e, p) => e.tags)
)
// DEPRECATED
addHandler(12165, 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(
10001,
profileHandler("relays", (e, p) => {
return e.tags.map(([url, read, write]) => ({url, read: read !== "!", write: write !== "!"}))
})
})
)
addHandler(10002, 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: e.tags.map(([_, url, mode]) => {
addHandler(
10002,
profileHandler("relays", (e, p) => {
return e.tags.map(([_, url, mode]) => {
const read = (mode || "read") === "read"
const write = (mode || "write") === "write"
return {url, read, write}
}),
})
})
})
)
// 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 {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 contacts = new Table("contacts", "pubkey")
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 {Readable} from 'svelte/store'
import {slice, identity, prop, find, pipe, assoc, whereEq, when, concat, reject, nth, map} from 'ramda'
import {findReplyId, findRootId} from 'src/util/nostr'
import {synced} from 'src/util/misc'
import {derived} from 'svelte/store'
import {people} from 'src/agent/state'
import keys from 'src/agent/keys'
import cmd from 'src/agent/cmd'
import type {Relay} from "src/util/types"
import type {Readable} from "svelte/store"
import {slice, prop, find, pipe, assoc, whereEq, when, concat, reject, nth, map} from "ramda"
import {findReplyId, findRootId} from "src/util/nostr"
import {synced} from "src/util/misc"
import {derived} from "svelte/store"
import keys from "src/agent/keys"
import cmd from "src/agent/cmd"
// Create a special wrapper to manage profile data, follows, and relays in the same
// way whether the user is logged in or not. This involves creating a store that we
// allow an anonymous user to write to, then once the user logs in we use that until
// we have actual event data for them, which we then prefer. For extra fun, we also
// sync this stuff to regular private variables so we don't have to constantly call
// `get` on our stores.
let settingsCopy = null
let profileCopy = null
let petnamesCopy = []
let relaysCopy = []
let mutesCopy = []
const anonPetnames = synced('agent/user/anonPetnames', [])
const anonRelays = synced('agent/user/anonRelays', [])
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 = synced("agent/user/profile", {
pubkey: null,
kind0: null,
lnurl: null,
zapper: null,
settings: {
relayLimit: 20,
defaultZap: 21,
showMedia: true,
reportAnalytics: true,
dufflepudUrl: import.meta.env.VITE_DUFFLEPUD_URL,
},
petnames: [],
relays: [],
mutes: [],
})
const profile = derived(
[keys.pubkey, people as Readable<any>],
([pubkey, t]) => pubkey ? (t.get(pubkey) || {pubkey}) : null
) as Readable<Person>
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 settings = derived(profile, prop("settings"))
const petnames = derived(profile, prop("petnames"))
const relays = derived(profile, prop("relays")) as Readable<Array<Relay>>
const mutes = derived(profile, prop("mutes"))
const canPublish = derived(
[keys.pubkey, relays],
([$pubkey, $relays]) =>
keys.canSign() && find(prop('write'), $relays)
([$pubkey, $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 => {
settingsCopy = $settings
})
let profileCopy = null
profile.subscribe($profile => {
profileCopy = $profile
})
petnames.subscribe($petnames => {
petnamesCopy = $petnames
// Watch pubkey and add to profile
keys.pubkey.subscribe($pubkey => {
if ($pubkey) {
profile.update($p => ({...$p, pubkey: $pubkey}))
}
})
mutes.subscribe($mutes => {
mutesCopy = $mutes
})
relays.subscribe($relays => {
relaysCopy = $relays
})
const user = {
// Settings
settings,
getSettings: () => settingsCopy,
getSetting: k => settingsCopy[k],
dufflepud: path => `${settingsCopy.dufflepudUrl}${path}`,
export default {
// Profile
profile,
@ -93,24 +58,36 @@ const user = {
getProfile: () => profileCopy,
getPubkey: () => profileCopy?.pubkey,
// Settings
settings,
getSettings: () => profileCopy.settings,
getSetting: k => profileCopy.settings[k],
dufflepud: path => `${profileCopy.settings.dufflepudUrl}${path}`,
// Petnames
petnames,
getPetnames: () => petnamesCopy,
getPetnames: () => profileCopy.petnames,
petnamePubkeys: derived(petnames, map(nth(1))) as Readable<Array<string>>,
updatePetnames(f) {
const $petnames = f(petnamesCopy)
const $petnames = f(profileCopy.petnames)
anonPetnames.set($petnames)
profile.update(assoc("petnames", $petnames))
if (profileCopy) {
return cmd.setPetnames($petnames).publish(relaysCopy)
return cmd.setPetnames($petnames).publish(profileCopy.relays)
}
},
addPetname(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) {
return this.updatePetnames(reject(t => t[1] === pubkey))
@ -119,11 +96,11 @@ const user = {
// Relays
relays,
getRelays: () => relaysCopy,
getRelays: () => profileCopy.relays,
updateRelays(f) {
const $relays = f(relaysCopy)
const $relays = f(profileCopy.relays)
anonRelays.set($relays)
profile.update(assoc("relays", $relays))
if (profileCopy) {
return cmd.setRelays($relays).publish($relays)
@ -136,28 +113,27 @@ const user = {
return this.updateRelays(reject(whereEq({url})))
},
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,
getMutes: () => mutesCopy,
getMutes: () => profileCopy.mutes,
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 =>
!(m.has(e.id) || m.has(e.pubkey) || m.has(findReplyId(e)) || m.has(findRootId(e)))
return events.filter(
e => !(m.has(e.id) || m.has(e.pubkey) || m.has(findReplyId(e)) || m.has(findRootId(e)))
)
},
updateMutes(f) {
const $mutes = f(mutesCopy)
console.log(mutesCopy, $mutes)
const $mutes = f(profileCopy.mutes)
anonMutes.set($mutes)
profile.update(assoc("mutes", $mutes))
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) {
@ -172,5 +148,3 @@ const user = {
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 {max, find, pluck, propEq, partition, uniq} from 'ramda'
import {derived} from 'svelte/store'
import {createMap} from 'hurdak/lib/hurdak'
import {synced, tryJson, now, timedelta} from 'src/util/misc'
import {Tags, personKinds, isAlert, asDisplayEvent, findReplyId} from 'src/util/nostr'
import {getUserReadRelays} from 'src/agent/relays'
import {alerts, contacts, rooms} from 'src/agent/state'
import {watch} from 'src/agent/table'
import network from 'src/agent/network'
import type {DisplayEvent} from "src/util/types"
import {max, find, pluck, propEq, partition, uniq} from "ramda"
import {derived} from "svelte/store"
import {createMap} from "hurdak/lib/hurdak"
import {synced, tryJson, now, timedelta} from "src/util/misc"
import {Tags, userKinds, isAlert, asDisplayEvent, findReplyId} from "src/util/nostr"
import {getUserReadRelays} from "src/agent/relays"
import {alerts, contacts, rooms} from "src/agent/tables"
import {watch} from "src/agent/storage"
import network from "src/agent/network"
let listener
@ -20,25 +20,23 @@ type AlertEvent = DisplayEvent & {
// 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(
[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)
)
export const newDirectMessages = derived(
[watch('contacts', t => t.all()), lastChecked],
([contacts, $lastChecked]) =>
Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], contacts))
[watch("contacts", t => t.all()), lastChecked],
([contacts, $lastChecked]) => Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], contacts))
)
export const newChatMessages = derived(
[watch('rooms', t => t.all()), lastChecked],
([rooms, $lastChecked]) =>
Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], rooms))
[watch("rooms", t => t.all()), lastChecked],
([rooms, $lastChecked]) => Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], rooms))
)
// Synchronization from events to state
@ -59,10 +57,15 @@ const processAlerts = async (pubkey, events) => {
return
}
const parents = createMap('id', await network.loadParents(events))
const parents = createMap("id", await network.loadParents(events))
const asAlert = (e): AlertEvent =>
({repliesFrom: [], likedBy: [], zappedBy: [], isMention: false, ...asDisplayEvent(e)})
const asAlert = (e): AlertEvent => ({
repliesFrom: [],
likedBy: [],
zappedBy: [],
isMention: false,
...asDisplayEvent(e),
})
const isPubkeyChild = e => {
const parentId = findReplyId(e)
@ -70,9 +73,9 @@ const processAlerts = async (pubkey, events) => {
return parents[parentId]?.pubkey === pubkey
}
const [replies, mentions] = partition(isPubkeyChild, events.filter(propEq('kind', 1)))
const likes = events.filter(propEq('kind', 7))
const zaps = events.filter(propEq('kind', 9735))
const [replies, mentions] = partition(isPubkeyChild, events.filter(propEq("kind", 1)))
const likes = events.filter(propEq("kind", 7))
const zaps = events.filter(propEq("kind", 9735))
zaps.filter(isPubkeyChild).forEach(e => {
const parent = parents[findReplyId(e)]
@ -107,7 +110,7 @@ const processAlerts = 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) {
return
@ -133,7 +136,7 @@ const processMessages = 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) {
return
@ -159,8 +162,8 @@ const processChats = async (pubkey, events) => {
const listen = async pubkey => {
// Include an offset so we don't miss alerts on one relay but not another
const since = now() - timedelta(7, 'days')
const roomIds = pluck('id', rooms.all({joined: true}))
const since = now() - timedelta(7, "days")
const roomIds = pluck("id", rooms.all({joined: true}))
if (listener) {
listener.unsub()
@ -170,13 +173,13 @@ const listen = async pubkey => {
delay: 10000,
relays: getUserReadRelays(),
filter: [
{kinds: personKinds, authors: [pubkey], since},
{kinds: userKinds, authors: [pubkey], since},
{kinds: [4], authors: [pubkey], since},
{kinds: [1, 7, 4, 9735], '#p': [pubkey], since},
{kinds: [42], '#e': roomIds, since},
{kinds: [1, 7, 4, 9735], "#p": [pubkey], since},
{kinds: [42], "#e": roomIds, since},
],
onChunk: async events => {
await network.loadPeople(pluck('pubkey', events))
await network.loadPeople(pluck("pubkey", events))
await processMessages(pubkey, events)
await processAlerts(pubkey, events)
await processChats(pubkey, events)

View File

@ -1,15 +1,15 @@
import type {DisplayEvent} from 'src/util/types'
import {omit, sortBy} from 'ramda'
import {createMap, ellipsize} from 'hurdak/lib/hurdak'
import {renderContent} from 'src/util/html'
import {displayPerson, findReplyId} from 'src/util/nostr'
import {getUserFollows} from 'src/agent/social'
import {getUserReadRelays} from 'src/agent/relays'
import {getPersonWithFallback} from 'src/agent/state'
import network from 'src/agent/network'
import keys from 'src/agent/keys'
import alerts from 'src/app/alerts'
import {routes, modal, toast} from 'src/app/ui'
import type {DisplayEvent} from "src/util/types"
import {omit, sortBy} from "ramda"
import {createMap, ellipsize} from "hurdak/lib/hurdak"
import {renderContent} from "src/util/html"
import {displayPerson, findReplyId} from "src/util/nostr"
import {getUserFollows} from "src/agent/social"
import {getUserReadRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/tables"
import network from "src/agent/network"
import keys from "src/agent/keys"
import alerts from "src/app/alerts"
import {routes, modal, toast} from "src/app/ui"
export const loadAppData = async pubkey => {
if (getUserReadRelays().length > 0) {
@ -24,40 +24,37 @@ export const loadAppData = async pubkey => {
export const 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}) => {
let content
// Ellipsize
content = note.content.length > 500 && !showEntire
? ellipsize(note.content, 500)
: note.content
content = note.content.length > 500 && !showEntire ? ellipsize(note.content, 500) : note.content
// Escape html, replace urls
content = renderContent(content)
// Mentions
content = content
.replace(/#\[(\d+)\]/g, (tag, i) => {
if (!note.tags[parseInt(i)]) {
return tag
}
content = content.replace(/#\[(\d+)\]/g, (tag, i) => {
if (!note.tags[parseInt(i)]) {
return tag
}
const pubkey = note.tags[parseInt(i)][1]
const person = getPersonWithFallback(pubkey)
const name = displayPerson(person)
const path = routes.person(pubkey)
const pubkey = note.tags[parseInt(i)][1]
const person = getPersonWithFallback(pubkey)
const name = displayPerson(person)
const path = routes.person(pubkey)
return `@<a href="${path}" class="underline">${name}</a>`
})
return `@<a href="${path}" class="underline">${name}</a>`
})
return content
}
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 = []
for (const note of Object.values(notesById)) {
@ -94,8 +91,8 @@ export const publishWithToast = (relays, thunk) =>
}
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 Spinner from "src/partials/Spinner.svelte"
import user from "src/agent/user"
import {getPersonWithFallback} from "src/agent/state"
import {getPersonWithFallback} from "src/agent/tables"
import network from "src/agent/network"
export let loadMessages

View File

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

View File

@ -7,7 +7,7 @@
import Content from "src/partials/Content.svelte"
import Alert from "src/views/alerts/Alert.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 {lastChecked} from "src/app/alerts"

View File

@ -9,7 +9,7 @@
import user from "src/agent/user"
import {getRelaysForEventChildren, sampleRelays} from "src/agent/relays"
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 {modal} from "src/app/ui"
import {lastChecked} from "src/app/alerts"

View File

@ -5,7 +5,7 @@
import Content from "src/partials/Content.svelte"
import Anchor from "src/partials/Anchor.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 {getUserReadRelays} from "src/agent/relays"
import {modal} from "src/app/ui"

View File

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

View File

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

View File

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

View File

@ -18,7 +18,7 @@
import user from "src/agent/user"
import {sampleRelays, getPubkeyWriteRelays} from "src/agent/relays"
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 PersonCircle from "src/partials/PersonCircle.svelte"

View File

@ -7,7 +7,7 @@
import Content from "src/partials/Content.svelte"
import Anchor from "src/partials/Anchor.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 user from "src/agent/user"

View File

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

View File

@ -1,9 +1,10 @@
import type {DisplayEvent} from 'src/util/types'
import {is, fromPairs, mergeLeft, last, identity, objOf, prop, flatten, uniq} from 'ramda'
import {nip19} from 'nostr-tools'
import {ensurePlural, ellipsize, first} from 'hurdak/lib/hurdak'
import type {DisplayEvent} from "src/util/types"
import {is, fromPairs, mergeLeft, last, identity, objOf, prop, flatten, uniq} from "ramda"
import {nip19} from "nostr-tools"
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 {
tags: Array<any>
@ -11,7 +12,7 @@ export class Tags {
this.tags = tags
}
static from(events) {
return new Tags(ensurePlural(events).flatMap(prop('tags')))
return new Tags(ensurePlural(events).flatMap(prop("tags")))
}
static wrap(tags) {
return new Tags((tags || []).filter(identity))
@ -26,7 +27,7 @@ export class Tags {
return last(this.tags)
}
relays() {
return uniq(flatten(this.tags).filter(isShareableRelay)).map(objOf('url'))
return uniq(flatten(this.tags).filter(isShareableRelay)).map(objOf("url"))
}
pubkeys() {
return this.type("p").values().all()
@ -55,12 +56,17 @@ export class Tags {
export const findReply = e =>
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 =>
Tags.from(e).type("e").mark("root").first()
export const findRoot = e => 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 => {
if (p.kind0?.display_name) {
@ -76,13 +82,13 @@ export const displayPerson = p => {
} catch (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) => {
if (![1, 7, 9735].includes(e.kind)) {
@ -102,28 +108,26 @@ export const isAlert = (e, pubkey) => {
return true
}
export const isRelay = url => (
typeof url === 'string'
export const isRelay = url =>
typeof url === "string" &&
// It should have the protocol included
&& url.match(/^wss?:\/\/.+/)
)
url.match(/^wss?:\/\/.+/)
export const isShareableRelay = url => (
isRelay(url)
export const isShareableRelay = url =>
isRelay(url) &&
// Don't match stuff with a port number
&& !url.slice(6).match(/:\d+/)
!url.slice(6).match(/:\d+/) &&
// 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
&& !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 =>
({replies: [], reactions: [], zaps: [], ...event}) as DisplayEvent
({replies: [], reactions: [], zaps: [], ...event} as DisplayEvent)
export const toHex = (data: string): string | null => {
try {

View File

@ -6,7 +6,7 @@
import RelaySearch from "src/views/relays/RelaySearch.svelte"
import RelayCard from "src/views/relays/RelayCard.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"
export let enforceRelays = true

View File

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

View File

@ -5,7 +5,7 @@
import {menuIsOpen, installPrompt, routes} from "src/app/ui"
import {newAlerts, newDirectMessages, newChatMessages} from "src/app/alerts"
import {slowConnections} from "src/app/connection"
import PersonCircle from "src/partials/PersonCircle.svelte";
import PersonCircle from "src/partials/PersonCircle.svelte"
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
pb-20 text-white shadow-xl transition-all lg:mt-0 lg:ml-0"
class:-ml-56={!$menuIsOpen}>
{#if $profile}
{#if $profile.pubkey}
<li>
<a href={routes.person($profile.pubkey)} class="flex items-center gap-2 px-4 py-2 pb-6">
<PersonCircle size={6} person={$profile} />

View File

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

View File

@ -4,7 +4,7 @@
import {displayPerson} from "src/util/nostr"
import Popover from "src/partials/Popover.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 PersonCircle from "src/partials/PersonCircle.svelte"

View File

@ -8,7 +8,7 @@
import Textarea from "src/partials/Textarea.svelte"
import Button from "src/partials/Button.svelte"
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 {toast, modal} from "src/app/ui"
import {publishWithToast} from "src/app"

View File

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

View File

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

View File

@ -4,7 +4,7 @@
import {fly} from "svelte/transition"
import {ellipsize} from "hurdak/lib/hurdak"
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 PersonCircle from "src/partials/PersonCircle.svelte"

View File

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

View File

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

View File

@ -13,7 +13,7 @@
import OnboardingComplete from "src/views/onboarding/OnboardingComplete.svelte"
import {getFollows} from "src/agent/social"
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 user from "src/agent/user"
import keys from "src/agent/keys"

View File

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

View File

@ -6,7 +6,7 @@
import Heading from "src/partials/Heading.svelte"
import Content from "src/partials/Content.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"
export let relays

View File

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

View File

@ -5,7 +5,7 @@
import Spinner from "src/partials/Spinner.svelte"
import PersonInfo from "src/views/person/PersonInfo.svelte"
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 user from "src/agent/user"

View File

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

View File

@ -3,7 +3,7 @@
import {fuzzy} from "src/util/misc"
import Input from "src/partials/Input.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"
let q = ""