Split cursor and scroller up

This commit is contained in:
Jonathan Staab 2022-11-30 10:53:12 -08:00
parent ee04e2987a
commit 995904a2b2
7 changed files with 95 additions and 97 deletions

View File

@ -1,5 +1,6 @@
Bugs
- [ ] Use cursor for chat rooms
- [ ] Load/publish user preferred relays
- [ ] Optimistically load events the user publishes (e.g. to reduce reflow for reactions/replies). Essentially, we can pretend to be our own in-memory relay.

View File

@ -8,7 +8,7 @@
import Anchor from 'src/partials/Anchor.svelte'
import {dispatch} from "src/state/dispatch"
import {channels, findReplyTo} from "src/state/nostr"
import {accounts, modal, annotateNotesChunk} from "src/state/app"
import {accounts, modal, annotateNotes} from "src/state/app"
import {user} from "src/state/user"
import {formatTimestamp} from 'src/util/misc'
import UserBadge from "src/partials/UserBadge.svelte"
@ -37,7 +37,7 @@
}
const showParent = async () => {
const notes = await annotateNotesChunk(
const notes = await annotateNotes(
await channels.getter.all({kinds: [1, 5, 7], ids: [parentId]})
)

View File

@ -2,38 +2,30 @@
import {onMount} from 'svelte'
import {writable} from 'svelte/store'
import {find, propEq} from 'ramda'
import {notesLoader, notesListener, modal} from "src/state/app"
import {Cursor} from "src/state/nostr"
import {notesListener, modal} from "src/state/app"
import {user} from "src/state/user"
import Note from 'src/partials/Note.svelte'
export let note
const notes = writable([note])
let loader
let cursor
let listener
onMount(() => {
const opts = {isInModal: true}
if (note.created_at) {
opts.since = note.created_at
}
cursor = new Cursor({ids: [note.id]}, note.created_at)
// Can't use async/await since we need to return unsubscribe functions
Promise.all([
notesLoader(notes, {ids: [note.id]}, opts),
notesListener(notes, [
{kinds: [1, 5, 7], '#e': [note.id]},
// We can't target reaction deletes by e tag, so get them
// all so we can support toggling like/flags for our user
{kinds: [5], authors: $user ? [$user.pubkey] : []}
]),
]).then(([_loader, _listener]) => {
loader = _loader
notesListener(notes, [
{kinds: [1, 5, 7], '#e': [note.id]},
// We can't target reaction deletes by e tag, so get them
// all so we can support toggling like/flags for our user
{kinds: [5], authors: $user ? [$user.pubkey] : []}
]).then(_listener => {
listener = _listener
// Populate our initial empty space
loader.onScroll()
listener.start()
})
@ -43,7 +35,7 @@
// Unsubscribe when modal closes so that others can re-subscribe sooner
const unsubModal = modal.subscribe($modal => {
loader?.stop()
cursor?.stop()
listener?.stop()
})
@ -54,8 +46,6 @@
})
</script>
<svelte:window on:scroll={loader?.onScroll} />
{#if note.pubkey}
<Note showEntire note={note} />
{#each note.replies as r (r.id)}

View File

@ -2,45 +2,52 @@
import {onMount, onDestroy} from 'svelte'
import {writable} from 'svelte/store'
import {navigate} from "svelte-routing"
import {uniqBy, prop} from 'ramda'
import Anchor from "src/partials/Anchor.svelte"
import Note from "src/partials/Note.svelte"
import {relays} from "src/state/nostr"
import {notesLoader, notesListener, modal} from "src/state/app"
import {relays, Cursor} from "src/state/nostr"
import {scroller, annotateNotes, notesListener, modal} from "src/state/app"
const notes = writable([])
let loader
let cursor
let listener
let scroll
const createNote = () => {
navigate("/notes/new")
}
onMount(async () => {
loader = await notesLoader(notes, {kinds: [1]}, {showParents: true})
cursor = new Cursor({kinds: [1]})
listener = await notesListener(notes, {kinds: [1, 5, 7]})
scroll = scroller(cursor, async chunk => {
const annotated = await annotateNotes(chunk, {showParents: true})
notes.update($notes => uniqBy(prop('id'), $notes.concat(annotated)))
})
// Populate our initial empty space
loader.onScroll()
scroll()
// When a modal opens, suspend our subscriptions
modal.subscribe(async $modal => {
if ($modal) {
loader.stop()
cursor.stop()
listener.stop()
} else {
loader.start()
cursor.start()
listener.start()
}
})
})
onDestroy(() => {
loader?.stop()
cursor?.stop()
listener?.stop()
})
</script>
<svelte:window on:scroll={loader?.onScroll} />
<svelte:window on:scroll={scroll} />
<ul class="py-8 flex flex-col gap-2 max-w-xl m-auto">
{#each (notes ? $notes : []) as n (n.id)}

View File

@ -1,46 +1,54 @@
<script>
import {onMount, onDestroy} from 'svelte'
import {writable} from 'svelte/store'
import {uniqBy, prop} from 'ramda'
import {fly} from 'svelte/transition'
import Note from "src/partials/Note.svelte"
import {Cursor} from 'src/state/nostr'
import {user as currentUser} from 'src/state/user'
import {accounts, notesLoader, notesListener, modal} from "src/state/app"
import {accounts, scroller, notesListener, modal, annotateNotes} from "src/state/app"
export let pubkey
const notes = writable([])
let user
let loader
let cursor
let listener
let scroll
$: user = $accounts[pubkey]
onMount(async () => {
loader = await notesLoader(notes, {kinds: [1], authors: [pubkey]}, {showParents: true})
cursor = new Cursor({kinds: [1], authors: [pubkey]})
listener = await notesListener(notes, {kinds: [1, 5, 7], authors: [pubkey]})
scroll = scroller(cursor, async chunk => {
const annotated = await annotateNotes(chunk, {showParents: true})
notes.update($notes => uniqBy(prop('id'), $notes.concat(annotated)))
})
// Populate our initial empty space
loader.onScroll()
scroll()
// When a modal opens, suspend our subscriptions
modal.subscribe(async $modal => {
if ($modal) {
loader.stop()
cursor.stop()
listener.stop()
} else {
loader.start()
cursor.start()
listener.start()
}
})
})
onDestroy(() => {
loader?.stop()
cursor?.stop()
listener?.stop()
})
</script>
<svelte:window on:scroll={loader?.onScroll} />
<svelte:window on:scroll={scroll} />
{#if user}
<div class="max-w-2xl m-auto flex flex-col gap-4 py-8 px-4">

View File

@ -5,7 +5,7 @@ import {navigate} from "svelte-routing"
import {switcherFn, ensurePlural} from 'hurdak/lib/hurdak'
import {getLocalJson, setLocalJson, now, timedelta, sleep} from "src/util/misc"
import {user} from 'src/state/user'
import {epoch, filterMatches, Listener, Cursor, channels, relays, findReplyTo} from 'src/state/nostr'
import {epoch, filterMatches, Listener, channels, relays, findReplyTo} from 'src/state/nostr'
export const modal = writable(null)
@ -63,7 +63,7 @@ export const ensureAccounts = async (pubkeys, {force = false} = {}) => {
// Notes
export const annotateNotesChunk = async (chunk, {showParents = false} = {}) => {
export const annotateNotes = async (chunk, {showParents = false} = {}) => {
const parentIds = chunk.map(findReplyTo).filter(identity)
if (showParents && parentIds.length) {
@ -116,56 +116,6 @@ export const annotateNotesChunk = async (chunk, {showParents = false} = {}) => {
return reverse(sortBy(prop('created'), chunk.map(annotate)))
}
export const notesLoader = async (
notes,
filter,
{
showParents = false,
delta = timedelta(1, 'hours'),
isInModal = false,
since = epoch,
} = {}
) => {
const cursor = new Cursor(filter, delta)
const onScroll = debounce(1000, async () => {
/* eslint no-constant-condition: 0 */
while (true) {
// If a modal opened up, wait for them to close it
if (!isInModal && get(modal)) {
await sleep(1000)
continue
}
// While we have empty space, fill it
if (window.scrollY + window.innerHeight * 3 < document.body.scrollHeight) {
break
}
// Stop if we've gone back far enough
if (cursor.since <= since) {
break
}
const chunk = await cursor.chunk()
const annotated = await annotateNotesChunk(chunk, {showParents})
notes.update($notes => uniqBy(prop('id'), $notes.concat(annotated)))
// 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 (annotated.length === 0) {
cursor.delta = Math.min(timedelta(30, 'days'), cursor.delta * 2)
} else {
cursor.delta = delta
}
}
})
return Object.assign(cursor, {onScroll})
}
export const notesListener = async (notes, filter) => {
const updateNote = (id, f) =>
notes.update($notes =>
@ -195,11 +145,11 @@ export const notesListener = async (notes, filter) => {
const id = findReplyTo(e)
if (id) {
const [reply] = await annotateNotesChunk([e])
const [reply] = await annotateNotes([e])
updateNote(id, n => ({...n, replies: n.replies.concat(reply)}))
} else if (filterMatches(filter, e)) {
const [note] = await annotateNotesChunk([e])
const [note] = await annotateNotes([e])
notes.update($notes => uniqBy(prop('id'), [note].concat($notes)))
}
@ -217,3 +167,45 @@ export const notesListener = async (notes, filter) => {
})
)
}
// UI
export const scroller = (cursor, cb, {isInModal = false, since = epoch} = {}) => {
const startingDelta = cursor.delta
return debounce(1000, async () => {
/* eslint no-constant-condition: 0 */
while (true) {
// If a modal opened up, wait for them to close it
if (!isInModal && get(modal)) {
await sleep(1000)
continue
}
// While we have empty space, fill it
if (window.scrollY + window.innerHeight * 3 < document.body.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
await cb(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
}
}
})
}

View File

@ -108,10 +108,10 @@ export const channels = {
// 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 = timedelta(1, 'hours')) {
constructor(filter, delta) {
this.filter = ensurePlural(filter)
this.delta = delta
this.since = now() - delta
this.delta = delta || timedelta(1, 'hours')
this.since = now() - this.delta
this.until = now()
this.sub = null
this.q = []