Some refactoring of data sync stuff

This commit is contained in:
Jonathan Staab 2022-12-17 10:51:25 -08:00
parent e4b67d914d
commit 1e0b09594b
29 changed files with 272 additions and 627 deletions

View File

@ -40,6 +40,10 @@ Coracle is currently in _alpha_ - expect bugs, slow loading times, and rough edg
- [ ] Check firefox - in dev it won't work, but it should in production
- [ ] Re-implement muffle
- [ ] Rename users/accounts to people
- [ ] Move relays to db
- [ ] Make user a livequery instead of a store
- [ ] Figure out if multiple relays congest response times because we wait for all eose
- [ ] Set default relay when storage is empty
- https://vitejs.dev/guide/features.html#web-workers
- https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers
- https://web.dev/module-workers/

View File

@ -12,9 +12,7 @@
import {hasParent} from 'src/util/html'
import {timedelta} from 'src/util/misc'
import {store as toast} from "src/state/toast"
import {channels} from "src/state/nostr"
import {modal, logout} from "src/state/app"
import {user} from 'src/state/user'
import {modal, alerts, user} from "src/state/app"
import relay from 'src/relay'
import Anchor from 'src/partials/Anchor.svelte'
import NoteDetail from "src/views/NoteDetail.svelte"
@ -29,12 +27,14 @@
import RelayList from "src/routes/RelayList.svelte"
import AddRelay from "src/routes/AddRelay.svelte"
import UserDetail from "src/routes/UserDetail.svelte"
// import UserAdvanced from "src/routes/UserAdvanced.svelte"
import UserAdvanced from "src/routes/UserAdvanced.svelte"
import NoteCreate from "src/routes/NoteCreate.svelte"
// import Chat from "src/routes/Chat.svelte"
// import ChatRoom from "src/routes/ChatRoom.svelte"
// import ChatEdit from "src/routes/ChatEdit.svelte"
export let url = ""
const menuIsOpen = writable(false)
const toggleMenu = () => menuIsOpen.update(x => !x)
@ -44,9 +44,24 @@
let menuIcon
let scrollY
let suspendedSubs = []
let mostRecentAlert = 0
let mostRecentAlert = relay.lq(async () => {
const [e] = await relay
.filterAlerts($user, {since: $alerts.since})
.limit(1).reverse().sortBy('created_at')
export let url = ""
return e?.created_at
})
const logout = () => {
// Give any animations a moment to finish
setTimeout(() => {
localStorage.clear()
relay.db.delete()
// Do a hard refresh so everything gets totally cleared
window.location = '/login'
}, 200)
}
// Close menu on click outside
document.querySelector("html").addEventListener("click", e => {
@ -56,7 +71,7 @@
})
onMount(() => {
relay.sync()
relay.pool.sync($user)
return modal.subscribe($modal => {
// Keep scroll position on body, but don't allow scrolling
@ -119,7 +134,7 @@
<div
class="overflow-hidden w-6 h-6 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
style="background-image: url({$user.picture})" />
<span class="text-lg font-bold">{$user.name}</span>
<span class="text-lg font-bold">{$user.name || $user.pubkey.slice(0, 8)}</span>
</a>
</li>
<li class="cursor-pointer relative">

View File

@ -3,7 +3,6 @@
import {onMount} from 'svelte'
import {navigate} from 'svelte-routing'
import {fuzzy} from "src/util/misc"
import {channels} from 'src/state/nostr'
import Input from "src/partials/Input.svelte"
export let className = ''

View File

@ -1,5 +1,7 @@
import Dexie from 'dexie'
import {filterTags} from 'src/util/nostr'
import {groupBy, prop, flatten, pick} from 'ramda'
import {ensurePlural, switcherFn} from 'hurdak/lib/hurdak'
import {filterTags, findReply, findRoot} from 'src/util/nostr'
export const db = new Dexie('coracle/relay')
@ -13,24 +15,58 @@ window.db = db
// Hooks
db.events.hook('creating', (id, e, t) => {
setTimeout(() => {
for (const tag of e.tags) {
db.tags.put({
id: [id, ...tag.slice(0, 2)].join(':'),
event: id,
type: tag[0],
value: tag[1],
relay: tag[2],
mark: tag[3],
})
}
db.events.process = async events => {
// Only persist ones we care about, the rest can be
// ephemeral and used to update users etc
const eventsByKind = groupBy(prop('kind'), ensurePlural(events))
const notesAndReactions = flatten(Object.values(pick([1, 7], eventsByKind)))
const profileUpdates = flatten(Object.values(pick([0, 3, 12165], eventsByKind)))
const deletions = eventsByKind[5] || []
if (e.kind === 5) {
const eventIds = filterTags({tag: "e"}, e)
// Persist notes and reactions
if (notesAndReactions.length > 0) {
const persistentEvents = notesAndReactions
.map(e => ({...e, root: findRoot(e), reply: findReply(e)}))
db.events.where('id').anyOf(eventIds).delete()
db.tags.where('event').anyOf(eventIds).delete()
}
})
})
db.events.bulkPut(persistentEvents)
db.tags.bulkPut(
persistentEvents
.flatMap(e =>
e.tags.map(
tag => ({
id: [e.id, ...tag.slice(0, 2)].join(':'),
event: e.id,
type: tag[0],
value: tag[1],
relay: tag[2],
mark: tag[3],
})
)
)
)
}
// Delete stuff that needs to be deleted
if (deletions.length > 0) {
const eventIds = deletions.flatMap(e => filterTags({tag: "e"}, e))
db.events.where('id').anyOf(eventIds).delete()
db.tags.where('event').anyOf(eventIds).delete()
}
// Update our users
for (const event of profileUpdates) {
const {pubkey, kind, content, tags} = event
const user = await db.users.where('pubkey').equals(pubkey).first()
await switcherFn(kind, {
0: () => db.users.put({...user, ...JSON.parse(content), pubkey}),
3: () => db.users.put({...user, petnames: tags, pubkey}),
12165: () => db.users.put({...user, muffle: tags, pubkey}),
default: () => {
console.log(`Received unsupported event type ${event.kind}`)
},
})
}
}

View File

@ -17,22 +17,13 @@ const lq = f => liveQuery(async () => {
})
const ensureContext = async e => {
// We can't return a promise, so use setTimeout instead
const user = await db.users.where('pubkey').equals(e.pubkey).first() || {
muffle: [],
petnames: [],
updated_at: 0,
pubkey: e.pubkey,
}
const user = await db.users.where('pubkey').equals(e.pubkey).first()
// Throttle updates for users
if (user.updated_at < now() - timedelta(1, 'hours')) {
Object.assign(user, await pool.getUserInfo({pubkey: e.pubkey, ...user}))
if (!user || user.updated_at < now() - timedelta(1, 'hours')) {
await pool.syncUserInfo({pubkey: e.pubkey, ...user})
}
// Even if we didn't find a match, save it so we don't keep trying to refresh
db.users.put({...user, updated_at: now()})
// TODO optimize this like user above so we're not double-fetching
await pool.fetchContext(e)
}
@ -130,7 +121,15 @@ const renderNote = async (note, {showEntire = false}) => {
})
}
const filterAlerts = async (user, filter) => {
const tags = db.tags.where('value').equals(user.pubkey)
const ids = pluck('event', await tags.toArray())
const events = await filterEvents({...filter, kinds: [1, 7], ids})
return events
}
export default {
db, pool, lq, ensureContext, filterEvents, filterReactions, countReactions,
findReaction, filterReplies, findNote, renderNote,
findReaction, filterReplies, findNote, renderNote, filterAlerts,
}

View File

@ -1,8 +1,8 @@
import {uniqBy, prop} from 'ramda'
import {relayPool, getPublicKey} from 'nostr-tools'
import {noop, switcherFn, uuid} from 'hurdak/lib/hurdak'
import {now, randomChoice, timedelta} from "src/util/misc"
import {filterTags, findReply, findRoot} from "src/util/nostr"
import {noop} from 'hurdak/lib/hurdak'
import {now, randomChoice, timedelta, getLocalJson, setLocalJson} from "src/util/misc"
import {filterTags, getTagValues} from "src/util/nostr"
import {db} from 'src/relay/db'
// ============================================================================
@ -81,8 +81,6 @@ export const channels = [
const req = filter => randomChoice(channels).all(filter)
const prepEvent = e => ({...e, root: findRoot(e), reply: findReply(e)})
const getPubkey = () => {
return pool._pubkey || getPublicKey(pool._privkey)
}
@ -113,25 +111,30 @@ const setPublicKey = pubkey => {
const publishEvent = event => {
pool.publish(event)
db.events.put(prepEvent(event))
db.events.process(event)
}
const loadEvents = async filter => {
const events = await req(filter)
db.events.bulkPut(events.map(prepEvent))
db.events.process(events)
}
const getUserInfo = async user => {
for (const e of await req({kinds: [0, 3, 12165], authors: [user.pubkey]})) {
switcherFn(e.kind, {
0: () => Object.assign(user, JSON.parse(e.content)),
3: () => Object.assign(user, {petnames: e.tags}),
12165: () => Object.assign(user, {muffle: e.tags}),
})
}
const syncUserInfo = async user => {
const [events] = await Promise.all([
// Get profile info events
req({kinds: [0, 3, 12165], authors: [user.pubkey]}),
// Make sure we have something in the database
db.users.put({muffle: [], petnames: [], updated_at: 0, ...user}),
])
return user
// Process the events to flesh out the user
await db.events.process(events)
// Return our user for convenience
const person = await db.users.where('pubkey').equals(user.pubkey).first()
return person
}
const fetchContext = async event => {
@ -140,39 +143,38 @@ const fetchContext = async event => {
{kinds: [5], 'ids': filterTags({tag: "e"}, event)},
])
db.events.bulkPut(events.map(prepEvent))
db.events.process(events)
}
let syncSub = null
const sync = async user => {
if (!user) throw new Error('No point sycing if we have no user')
if (syncSub) {
(await syncSub).unsub()
}
const channel = randomChoice(channels)
const since = Math.max(now() - interval(1, 'weeks'), getLocalJson('pool/lastSync') || 0)
if (!user) return
channel.sub(
[{since, authors: filterTags(user.petnames).concat(user.pubkey)},
{since, '#p': [user.pubkey]}],
onEvent: async e => {
if ([1, 5, 7].includes(e.kind)) {
return db.events.put(e)
}
const {petnames, pubkey} = await syncUserInfo(user)
const since = Math.max(
now() - timedelta(3, 'days'),
Math.min(
now() - timedelta(3, 'hours'),
getLocalJson('pool/lastSync') || 0
)
)
const {pubkey, kind, content, tags} = e
const user = await db.users.where('pubkey').equals(pubkey).first()
setLocalJson('pool/lastSync', now())
switcherFn(kind, {
0: () => db.users.put({...user, pubkey, ...JSON.parse(content)}),
3: () => db.users.put({...user, pubkey, petnames: e.tags}),
12165: () => db.users.put({...user, pubkey, muffle: e.tags}),
default: e => {
console.log(`Received unsupported event type ${e.kind}`)
},
})
}
// Populate recent activity in network so the user has something to look at right away
syncSub = randomChoice(channels).sub(
[{since, authors: getTagValues(petnames).concat(pubkey)},
{since, '#p': [pubkey]}],
db.events.process
)
}
export default {
getPubkey, addRelay, removeRelay, setPrivateKey, setPublicKey,
publishEvent, loadEvents, getUserInfo, fetchContext, sync,
publishEvent, loadEvents, syncUserInfo, fetchContext, sync,
}

View File

@ -1,8 +1,7 @@
<script>
import {fly} from 'svelte/transition'
import {registerRelay} from 'src/state/nostr'
import toast from 'src/state/toast'
import {modal} from 'src/state/app'
import {modal, registerRelay} from 'src/state/app'
import {dispatch} from 'src/state/dispatch'
import Input from 'src/partials/Input.svelte'
import Button from 'src/partials/Button.svelte'

View File

@ -4,17 +4,13 @@
import {findReply} from 'src/util/nostr'
import {ellipsize} from 'hurdak/src/core'
import relay from 'src/relay'
import {user} from 'src/state/user'
import {alerts, modal} from 'src/state/app'
import {alerts, modal, user} from 'src/state/app'
import UserBadge from "src/partials/UserBadge.svelte"
import Note from 'src/views/Note.svelte'
const events = relay.lq(async () => {
const events = await relay
.filterEvents({kinds: [1, 7], '#p': [$user.pubkey]})
.limit(10)
.reverse()
.sortBy('created_at')
const alerts = await relay.filterAlerts($user)
const events = await alerts.limit(10).reverse().sortBy('created_at')
return events
// Add parent in
@ -24,7 +20,7 @@
})
// Clear notification badge
alerts.set({...$alerts, since: now()})
alerts.set({since: now()})
</script>
<ul class="py-4 flex flex-col gap-2 max-w-xl m-auto">

View File

@ -2,7 +2,7 @@
import {onMount} from "svelte"
import {navigate} from "svelte-routing"
import RoomList from "src/partials/chat/RoomList.svelte"
import {user} from "src/state/user"
import {user} from "src/state/app"
onMount(() => {
if (!$user) {

View File

@ -7,7 +7,6 @@
import Textarea from "src/partials/Textarea.svelte"
import Button from "src/partials/Button.svelte"
import {dispatch} from "src/state/dispatch"
import {channels} from "src/state/nostr"
import toast from "src/state/toast"
export let room

View File

@ -6,10 +6,8 @@
import {formatTimestamp} from 'src/util/misc'
import {createScroller, renderNote} from 'src/util/notes'
import UserBadge from 'src/partials/UserBadge.svelte'
import {Listener, Cursor, epoch} from 'src/state/nostr'
import {accounts, ensureAccounts} from 'src/state/app'
import {user} from 'src/state/app'
import {dispatch} from 'src/state/dispatch'
import {user} from 'src/state/user'
import RoomList from "src/partials/chat/RoomList.svelte"
export let room

View File

@ -5,7 +5,7 @@
import {copyToClipboard} from "src/util/html"
import Input from "src/partials/Input.svelte"
import Anchor from "src/partials/Anchor.svelte"
import {user} from "src/state/user"
import {user} from "src/state/app"
import toast from "src/state/toast"
const keypairUrl = 'https://www.cloudflare.com/learning/ssl/how-does-public-key-encryption-work/'

View File

@ -8,7 +8,7 @@
import Input from "src/partials/Input.svelte"
import toast from "src/state/toast"
import {dispatch} from "src/state/dispatch"
import {relays} from "src/state/nostr"
import {relays, user} from "src/state/app"
let privkey = ''
let hasExtension = false
@ -32,11 +32,15 @@
}
const logIn = async ({privkey, pubkey}) => {
const {found} = await dispatch("account/init", {privkey, pubkey})
console.log(1)
const person = await dispatch("account/init", pubkey)
console.log(person)
user.set({...person, pubkey, privkey})
if ($relays.length === 0) {
navigate('/relays')
} else if (found) {
} else if (user.name) {
navigate('/notes/global')
} else {
navigate('/profile')

View File

@ -5,7 +5,7 @@
import Textarea from "src/partials/Textarea.svelte"
import Button from "src/partials/Button.svelte"
import {dispatch} from "src/state/dispatch"
import {user} from "src/state/user"
import {user} from "src/state/app"
import toast from "src/state/toast"
let values = {}

View File

@ -4,8 +4,7 @@
import Anchor from "src/partials/Anchor.svelte"
import Tabs from "src/partials/Tabs.svelte"
import Notes from "src/views/Notes.svelte"
import {relays} from "src/state/nostr"
import {user} from "src/state/user"
import {user, relays} from "src/state/app"
export let activeTab

View File

@ -8,7 +8,7 @@
import Textarea from "src/partials/Textarea.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Button from "src/partials/Button.svelte"
import {user} from "src/state/user"
import {user} from "src/state/app"
import {dispatch} from "src/state/dispatch"
import toast from "src/state/toast"
@ -38,15 +38,11 @@
const submit = async event => {
event.preventDefault()
if (!values.name.match(/^\w[\w-]+\w$/)) {
toast.show("error", "Names must be comprised of letters, numbers, and dashes only.")
} else {
await dispatch("account/update", values)
await dispatch("account/update", values)
navigate(`/users/${$user.pubkey}/profile`)
navigate(`/users/${$user.pubkey}/profile`)
toast.show("info", "Your profile has been updated!")
}
toast.show("info", "Your profile has been updated!")
}
</script>

View File

@ -4,8 +4,7 @@
import {fuzzy} from "src/util/misc"
import Input from "src/partials/Input.svelte"
import {dispatch} from "src/state/dispatch"
import {relays, knownRelays} from "src/state/nostr"
import {modal} from "src/state/app"
import {modal, relays, knownRelays} from "src/state/app"
let q = ""
let search
@ -15,7 +14,6 @@
$: search = fuzzy(data, {keys: ["name", "description", "url"]})
const join = url => dispatch("relay/join", url)
const leave = url => dispatch("relay/leave", url)
</script>

View File

@ -5,8 +5,7 @@
import Toggle from "src/partials/Toggle.svelte"
import Input from "src/partials/Input.svelte"
import Button from "src/partials/Button.svelte"
import {user} from "src/state/user"
import {settings} from "src/state/app"
import {settings, user} from "src/state/app"
import toast from "src/state/toast"
let values = {...$settings}

View File

@ -4,16 +4,15 @@
import {fly} from 'svelte/transition'
import Button from "src/partials/Button.svelte"
import SelectButton from "src/partials/SelectButton.svelte"
import {getMuffleValue} from "src/util/notes"
import {user} from 'src/state/user'
import {getMuffleValue} from "src/util/nostr"
import {dispatch, t} from 'src/state/dispatch'
import {modal} from "src/state/app"
import {modal, user} from "src/state/app"
const muffleOptions = ['Never', 'Sometimes', 'Often', 'Always']
const values = {
// Scale up to integers for each choice we have
muffle: switcher(Math.round(getMuffleValue($modal.user.pubkey) * 3), muffleOptions),
muffle: switcher(Math.round(getMuffleValue($modal.user) * 3), muffleOptions),
}
const save = async e => {

View File

@ -6,9 +6,8 @@
import Button from "src/partials/Button.svelte"
import Notes from "src/views/Notes.svelte"
import Likes from "src/views/Likes.svelte"
import {user as currentUser} from 'src/state/user'
import {t, dispatch} from 'src/state/dispatch'
import {modal} from "src/state/app"
import {modal, user as currentUser} from "src/state/app"
import relay from 'src/relay'
export let pubkey

View File

@ -1,9 +1,101 @@
import {writable} from 'svelte/store'
import {uniqBy, prop} from 'ramda'
import {writable, get} from 'svelte/store'
import {navigate} from "svelte-routing"
import {globalHistory} from "svelte-routing/src/history"
import {getLocalJson, setLocalJson, now, timedelta} from "src/util/misc"
import {user} from 'src/state/user'
import {relays} from 'src/state/nostr'
import relay from 'src/relay'
// Keep track of our user
export const user = writable(getLocalJson("coracle/user"))
user.subscribe($user => {
setLocalJson("coracle/user", $user)
// Keep nostr in sync
if ($user?.privkey) {
relay.pool.setPrivateKey($user.privkey)
} else if ($user?.pubkey) {
relay.pool.setPublicKey($user.pubkey)
}
})
const userLq = relay.lq(() => {
const $user = get(user)
if ($user) {
return relay.db.users.where('pubkey').equals($user?.pubkey).first()
}
})
userLq.subscribe(person => {
user.update($user => $user ? ({...$user, ...person}) : null)
})
// Keep track of known relays
export const knownRelays = writable((getLocalJson("coracle/knownRelays") || [
{url: "wss://nostr-pub.wellorder.net"},
{url: "wss://nostr.rocks"},
{url: "wss://nostr-pub.semisol.dev"},
{url: "wss://nostr.drss.io"},
{url: "wss://relay.damus.io"},
{url: "wss://nostr.openchain.fr"},
{url: "wss://nostr.delo.software"},
{url: "wss://relay.nostr.info"},
{url: "wss://nostr.ono.re"},
{url: "wss://relay.grunch.dev"},
{url: "wss://nostr.sandwich.farm"},
{url: "wss://relay.nostr.ch"},
{url: "wss://nostr-relay.wlvs.space"},
]).filter(x => x.url))
knownRelays.subscribe($knownRelays => {
setLocalJson("coracle/knownRelays", $knownRelays)
})
export const registerRelay = async url => {
let json
try {
const res = await fetch(url.replace(/^ws/, 'http'), {
headers: {
Accept: 'application/nostr_json',
},
})
json = await res.json()
} catch (e) {
json = {}
}
knownRelays.update($xs => uniqBy(prop('url'), $xs.concat({...json, url})))
}
// Keep track of which relays we're subscribed to
export const relays = writable(getLocalJson("coracle/relays") || [])
let prevRelays = []
relays.subscribe($relays => {
prevRelays.forEach(url => {
if (!$relays.includes(url)) {
relay.pool.removeRelay(url)
}
})
$relays.forEach(url => {
if (!prevRelays.includes(url)) {
relay.pool.addRelay(url)
}
})
setLocalJson("coracle/relays", $relays)
relay.pool.sync(get(user))
})
// Modals
export const modal = {
subscribe: cb => {
@ -26,6 +118,8 @@ export const modal = {
},
}
// Settings, alerts, etc
export const settings = writable({
showLinkPreviews: true,
dufflepudUrl: import.meta.env.VITE_DUFFLEPUD_URL,
@ -44,12 +138,3 @@ export const alerts = writable({
alerts.subscribe($alerts => {
setLocalJson("coracle/alerts", $alerts)
})
export const logout = () => {
// Give any animations a moment to finish
setTimeout(() => {
user.set(null)
relays.set([])
navigate("/login")
}, 200)
}

View File

@ -1,9 +1,8 @@
import {identity, isNil, uniqBy, last, without} from 'ramda'
import {get} from 'svelte/store'
import {first, defmulti} from "hurdak/lib/hurdak"
import {user} from "src/state/user"
import relay from 'src/relay'
import {relays} from 'src/state/nostr'
import {relays} from 'src/state/app'
// Commands are processed in two layers:
// - App-oriented commands are created via dispatch
@ -12,48 +11,19 @@ import {relays} from 'src/state/nostr'
export const dispatch = defmulti("dispatch", identity)
dispatch.addMethod("account/init", async (topic, { privkey, pubkey }) => {
// Set what we know about the user to our store
user.set({
name: pubkey.slice(0, 8),
privkey,
pubkey,
petnames: [],
muffle: [],
})
// Make sure we have data for this user
const {name} = await relay.pool.updateUser({pubkey})
// Tell the caller whether this user was found
return {found: Boolean(name)}
dispatch.addMethod("account/init", (topic, pubkey) => {
return relay.pool.syncUserInfo({pubkey})
})
dispatch.addMethod("account/update", async (topic, updates) => {
// Update our local copy
user.set({...get(user), ...updates})
// Tell the network
await relay.pool.publishEvent(makeEvent(0, JSON.stringify(updates)))
})
dispatch.addMethod("account/petnames", async (topic, petnames) => {
const $user = get(user)
// Update our local copy
user.set({...$user, petnames})
// Tell the network
await relay.pool.publishEvent(makeEvent(3, '', petnames))
})
dispatch.addMethod("account/muffle", async (topic, muffle) => {
const $user = get(user)
// Update our local copy
user.set({...$user, muffle})
// Tell the network
await relay.pool.publishEvent(makeEvent(12165, '', muffle))
})

View File

@ -1,238 +0,0 @@
import {writable, get} from 'svelte/store'
import {assoc, uniqBy, prop} from 'ramda'
import {noop, ensurePlural} from 'hurdak/lib/hurdak'
import relay from 'src/relay'
import {getLocalJson, setLocalJson, now, timedelta} from "src/util/misc"
export class Channel {
constructor(name) {
this.name = name
this.p = Promise.resolve()
}
async sub(filter, cb, onEose = noop) {
// Make sure callers have to wait for the previous sub to be done
// before they can get a new one.
await this.p
// If we don't have any relays, we'll wait forever for an eose, but
// we already know we're done. Use a timeout since callers are
// expecting this to be async and we run into errors otherwise.
if (get(relays).length === 0) {
setTimeout(onEose)
return {unsub: noop}
}
let resolve
const eoseRelays = []
const sub = relay.pool.sub({filter, cb}, this.name, r => {
eoseRelays.push(r)
if (eoseRelays.length === get(relays).length) {
onEose()
}
})
this.p = new Promise(r => {
resolve = r
})
return {
unsub: () => {
sub.unsub()
resolve()
}
}
}
all(filter) {
/* eslint no-async-promise-executor: 0 */
return new Promise(async resolve => {
const result = []
const sub = await this.sub(
filter,
e => result.push(e),
r => {
sub.unsub()
resolve(uniqBy(prop('id'), result))
},
)
})
}
}
export const channels = {
listener: new Channel('listener'),
getter: new Channel('getter'),
}
// We want to get old events, then listen for new events, then potentially retrieve
// older events again for pagination. Since we have to limit channels to 3 per nip 01,
// this requires us to unsubscribe and re-subscribe frequently
export class Cursor {
constructor(filter, delta) {
this.filter = ensurePlural(filter)
this.delta = delta || timedelta(15, 'minutes')
this.since = now() - this.delta
this.until = now()
this.sub = null
this.q = []
this.p = Promise.resolve()
this.seen = new Set()
}
async start() {
if (!this.since) {
throw new Error("Since must not be null")
}
if (!this.sub) {
this.sub = await channels.getter.sub(
this.filter.map(f => ({...f, since: this.since, until: this.until})),
e => this.onEvent(e),
r => this.onEose(r)
)
}
}
stop() {
if (this.sub) {
this.sub.unsub()
this.sub = null
}
}
async restart() {
this.stop()
await this.start()
}
async step() {
this.since -= this.delta
await this.restart()
}
onEvent(e) {
// Save a little memory
const shortId = e.id.slice(-10)
if (!this.seen.has(shortId)) {
this.seen.add(shortId)
this.q.push(e)
}
this.until = Math.min(this.until, e.created_at - 1)
}
onEose() {
this.stop()
}
async chunk() {
await this.step()
/* eslint no-constant-condition: 0 */
while (true) {
await new Promise(requestAnimationFrame)
if (!this.sub) {
return this.q.splice(0)
}
}
}
}
export class Listener {
constructor(filter, onEvent) {
this.filter = ensurePlural(filter).map(assoc('since', now()))
this.onEvent = onEvent
this.since = now()
this.sub = null
this.q = []
this.p = Promise.resolve()
}
async start() {
const {filter, since} = this
if (!this.sub) {
this.sub = await channels.listener.sub(
filter.map(f => ({since, ...f})),
e => {
// Not sure why since filter isn't working here, it's just slightly off
if (e.created_at >= since) {
this.since = e.created_at + 1
this.onEvent(e)
}
}
)
}
}
stop() {
if (this.sub) {
this.sub.unsub()
this.sub = null
}
}
restart() {
this.stop()
this.start()
}
}
// Keep track of known relays
export const knownRelays = writable((getLocalJson("coracle/knownRelays") || [
{url: "wss://nostr-pub.wellorder.net"},
{url: "wss://nostr.rocks"},
{url: "wss://nostr-pub.semisol.dev"},
{url: "wss://nostr.drss.io"},
{url: "wss://relay.damus.io"},
{url: "wss://nostr.openchain.fr"},
{url: "wss://nostr.delo.software"},
{url: "wss://relay.nostr.info"},
{url: "wss://nostr.ono.re"},
{url: "wss://relay.grunch.dev"},
{url: "wss://nostr.sandwich.farm"},
{url: "wss://relay.nostr.ch"},
{url: "wss://nostr-relay.wlvs.space"},
]).filter(x => x.url))
knownRelays.subscribe($knownRelays => {
setLocalJson("coracle/knownRelays", $knownRelays)
})
export const registerRelay = async url => {
let json
try {
const res = await fetch(url.replace(/^ws/, 'http'), {
headers: {
Accept: 'application/nostr_json',
},
})
json = await res.json()
} catch (e) {
json = {}
}
knownRelays.update($xs => uniqBy(prop('url'), $xs.concat({...json, url})))
}
// Create writable store for relays so we can observe changes in the app
export const relays = writable(getLocalJson("coracle/relays") || [])
let prevRelays = []
relays.subscribe($relays => {
prevRelays.forEach(url => {
if (!$relays.includes(url)) {
relay.pool.removeRelay(url)
}
})
$relays.forEach(url => {
if (!prevRelays.includes(url)) {
relay.pool.addRelay(url)
}
})
setLocalJson("coracle/relays", $relays)
})

View File

@ -1,22 +0,0 @@
import {writable} from "svelte/store"
import {getLocalJson, setLocalJson} from "src/util/misc"
import relay from 'src/relay'
export const user = writable(getLocalJson("coracle/user"))
user.subscribe($user => {
setLocalJson("coracle/user", $user)
// Keep nostr in sync
if ($user?.privkey) {
relay.pool.setPrivateKey($user.privkey)
} else if ($user?.pubkey) {
relay.pool.setPublicKey($user.pubkey)
}
// Migrate data from old formats
if ($user && (!$user.petnames || !$user.muffle)) {
user.set({...$user, petnames: [], muffle: []})
}
})

View File

@ -1,8 +1,10 @@
import {last, intersection} from 'ramda'
import {last, find, intersection} from 'ramda'
import {ensurePlural, first} from 'hurdak/lib/hurdak'
export const epoch = 1633046400
export const getTagValues = tags => tags.map(t => t[1])
export const filterTags = (where, events) =>
ensurePlural(events)
.flatMap(
@ -45,14 +47,12 @@ export const filterMatches = (filter, e) => {
))
}
export const getMuffleValue = pubkey => {
const $user = get(user)
if (!$user) {
export const getMuffleValue = user => {
if (!user) {
return 1
}
const tag = find(t => t[1] === pubkey, $user.muffle)
const tag = find(t => t[1] === user.pubkey, user.muffle)
if (!tag) {
return 1

View File

@ -1,190 +0,0 @@
import {identity, uniq, propEq, uniqBy, prop, groupBy, pluck} from 'ramda'
import {debounce} from 'throttle-debounce'
import {get} from 'svelte/store'
import {getMuffleValue, epoch, filterMatches, findReply} from 'src/util/nostr'
import {switcherFn, createMap} from 'hurdak/lib/hurdak'
import {timedelta, sleep} from "src/util/misc"
import {Listener, channels} from 'src/state/nostr'
export const annotateNotes = async (notes, {showParent = false} = {}) => {
if (notes.length === 0) {
return []
}
const noteIds = pluck('id', notes)
const parentIds = notes.map(findReply).filter(identity)
const filters = [{kinds: [1, 7], '#e': noteIds}]
if (showParent && parentIds.length > 0) {
filters.push({kinds: [1], ids: parentIds})
filters.push({kinds: [7], '#e': parentIds})
}
const events = await channels.getter.all(filters)
await ensureAccounts(uniq(pluck('pubkey', notes.concat(events))))
const $accounts = get(accounts)
const reactionsByParent = groupBy(findReply, events.filter(propEq('kind', 7)))
const allNotes = uniqBy(prop('id'), notes.concat(events.filter(propEq('kind', 1))))
const notesById = createMap('id', allNotes)
const annotate = note => ({
...note,
user: $accounts[note.pubkey],
reactions: reactionsByParent[note.id] || [],
children: uniqBy(prop('id'), allNotes.filter(n => findReply(n) === note.id)).map(annotate),
})
return notes.map(note => {
const parentId = findReply(note)
// If we have a parent, return that instead
return annotate(
showParent && notesById[parentId]
? notesById[parentId]
: note
)
})
}
export const annotateNewNote = async (note) => {
await ensureAccounts([note.pubkey])
const $accounts = get(accounts)
return {
...note,
user: $accounts[note.pubkey],
children: [],
reactions: [],
}
}
export const notesListener = (notes, filter, {shouldMuffle = false, repliesOnly = false} = {}) => {
const updateNote = (note, id, f) => {
if (note.id === id) {
return f(note)
}
return {
...note,
parent: note.parent ? updateNote(note.parent, id, f) : null,
children: note.children.map(n => updateNote(n, id, f)),
}
}
const updateNotes = (id, f) =>
notes.update($notes => $notes.map(n => updateNote(n, id, f)))
const deleteNote = (note, ids, deleted_at) => {
if (ids.includes(note.id)) {
return {...note, deleted_at}
}
return {
...note,
parent: note.parent ? deleteNote(note.parent, ids, deleted_at) : null,
children: note.children.map(n => deleteNote(n, ids, deleted_at)),
reactions: note.reactions.filter(e => !ids.includes(e.id)),
}
}
const deleteNotes = (ids, t) =>
notes.update($notes => $notes.map(n => deleteNote(n, ids, t)))
return new Listener(filter, e => switcherFn(e.kind, {
1: async () => {
const id = findReply(e)
const muffle = shouldMuffle && Math.random() > getMuffleValue(e.pubkey)
if (id) {
const note = await annotateNewNote(e)
updateNotes(id, n => ({...n, children: n.children.concat(note)}))
} else if (!repliesOnly && !muffle && filterMatches(filter, e)) {
const [note] = await threadify([e])
notes.update($notes => [note].concat($notes))
}
},
5: () => {
deleteNotes(e.tags.map(t => t[1]), e.created_at)
},
7: () => {
updateNotes(findReply(e), n => ({...n, reactions: n.reactions.concat(e)}))
}
}))
}
// UI
export const createScroller = (
cursor,
onChunk,
{since = epoch, reverse = false} = {}
) => {
const startingDelta = cursor.delta
let active = false
const start = debounce(1000, async () => {
if (active) {
return
}
active = true
/* eslint no-constant-condition: 0 */
while (true) {
// While we have empty space, fill it
const {scrollY, innerHeight} = window
const {scrollHeight} = document.body
if (
(reverse && scrollY > innerHeight * 2)
|| (!reverse && scrollY + innerHeight * 2 < scrollHeight)
) {
break
}
// Stop if we've gone back far enough
if (cursor.since <= since) {
break
}
// Get our chunk
const chunk = await cursor.chunk()
// Notify the caller
if (chunk.length > 0) {
await onChunk(chunk)
}
// If we have an empty chunk, increase our step size so we can get back to where
// we might have old events. Once we get a chunk, knock it down to the default again
if (chunk.length === 0) {
cursor.delta = Math.min(timedelta(30, 'days'), cursor.delta * 2)
} else {
cursor.delta = startingDelta
}
if (!active) {
break
}
// Wait a moment before proceeding to the next chunk for the caller
// to load results into the dom
await sleep(500)
}
active = false
})
return {
start,
stop: () => { active = false },
isActive: () => Boolean(cursor.sub),
}
}

View File

@ -9,8 +9,7 @@
import Anchor from 'src/partials/Anchor.svelte'
import relay from 'src/relay'
import {dispatch} from "src/state/dispatch"
import {settings, modal} from "src/state/app"
import {user} from "src/state/user"
import {settings, user, modal} from "src/state/app"
import {formatTimestamp} from 'src/util/misc'
import UserBadge from "src/partials/UserBadge.svelte"
import Card from "src/partials/Card.svelte"

View File

@ -4,7 +4,7 @@
export let note
const observable = relay.lq(() => relay.findNote(note.id, {showEntire: true}))
const observable = relay.lq(() => relay.findNote(note, {showEntire: true}))
</script>
{#if $observable}

View File

@ -1,7 +1,7 @@
<script>
import {fly} from 'svelte/transition'
import {fuzzy} from "src/util/misc"
import {user} from "src/state/user"
import {user} from "src/state/app"
import relay from 'src/relay'
export let q