Add forward scrolling to makeMakeFeed
This commit is contained in:
@@ -20,7 +20,7 @@ If you're deploying a custom version of flotilla, be sure to remove the `plausib
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
See [./CONTRIBUTING.md](CONTRIBUTING.md).
|
See [CONTRIBUTING.md](AGENTS.md).
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
+80
-40
@@ -9,9 +9,11 @@ import {
|
|||||||
sortBy,
|
sortBy,
|
||||||
now,
|
now,
|
||||||
on,
|
on,
|
||||||
|
between,
|
||||||
isDefined,
|
isDefined,
|
||||||
filterVals,
|
filterVals,
|
||||||
fromPairs,
|
fromPairs,
|
||||||
|
HOUR,
|
||||||
} from "@welshman/lib"
|
} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
EVENT_TIME,
|
EVENT_TIME,
|
||||||
@@ -23,9 +25,8 @@ import {
|
|||||||
getRelaysFromList,
|
getRelaysFromList,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {TrustedEvent, Filter, List} from "@welshman/util"
|
import type {TrustedEvent, Filter, List} from "@welshman/util"
|
||||||
import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds"
|
|
||||||
import {load, request} from "@welshman/net"
|
import {load, request} from "@welshman/net"
|
||||||
import {repository, makeFeedController, loadRelay, tracker} from "@welshman/app"
|
import {repository, loadRelay, tracker} from "@welshman/app"
|
||||||
import {createScroller} from "@lib/html"
|
import {createScroller} from "@lib/html"
|
||||||
import {daysBetween} from "@lib/util"
|
import {daysBetween} from "@lib/util"
|
||||||
import {getEventsForUrl} from "@app/core/state"
|
import {getEventsForUrl} from "@app/core/state"
|
||||||
@@ -36,55 +37,61 @@ export const makeFeed = ({
|
|||||||
url,
|
url,
|
||||||
filters,
|
filters,
|
||||||
element,
|
element,
|
||||||
onExhausted,
|
onBackwardExhausted,
|
||||||
|
onForwardExhausted,
|
||||||
|
at = now(),
|
||||||
}: {
|
}: {
|
||||||
url: string
|
url: string
|
||||||
filters: Filter[]
|
filters: Filter[]
|
||||||
element: HTMLElement
|
element: HTMLElement
|
||||||
onExhausted?: () => void
|
onBackwardExhausted?: () => void
|
||||||
|
onForwardExhausted?: () => void
|
||||||
|
at?: number
|
||||||
}) => {
|
}) => {
|
||||||
const seen = new Set<string>()
|
const interval = int(12, HOUR)
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const buffer = writable<TrustedEvent[]>([])
|
|
||||||
const events = writable<TrustedEvent[]>([])
|
const events = writable<TrustedEvent[]>([])
|
||||||
|
|
||||||
|
let buffer: TrustedEvent[] = []
|
||||||
|
let backwardWindow = [at - interval, at]
|
||||||
|
let forwardWindow = [at, at + interval]
|
||||||
|
|
||||||
const insertEvent = (event: TrustedEvent) => {
|
const insertEvent = (event: TrustedEvent) => {
|
||||||
let handled = false
|
let handled = false
|
||||||
|
|
||||||
if (seen.has(event.id)) {
|
if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) {
|
||||||
return
|
const $events = get(events)
|
||||||
}
|
|
||||||
|
|
||||||
events.update($events => {
|
|
||||||
for (let i = 0; i < $events.length; i++) {
|
for (let i = 0; i < $events.length; i++) {
|
||||||
if ($events[i].id === event.id) return $events
|
if ($events[i].created_at > event.created_at) {
|
||||||
if ($events[i].created_at < event.created_at) {
|
events.set(insertAt(i, event, $events))
|
||||||
handled = true
|
handled = true
|
||||||
return insertAt(i, event, $events)
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $events
|
if (!handled) {
|
||||||
})
|
events.set([...$events, event])
|
||||||
|
}
|
||||||
if (!handled) {
|
} else {
|
||||||
buffer.update($buffer => {
|
for (let i = 0; i < buffer.length; i++) {
|
||||||
for (let i = 0; i < $buffer.length; i++) {
|
if (buffer[i].created_at > event.created_at) {
|
||||||
if ($buffer[i].id === event.id) return $buffer
|
buffer.splice(i, 0, event)
|
||||||
if ($buffer[i].created_at < event.created_at) return insertAt(i, event, $buffer)
|
handled = true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [...$buffer, event]
|
if (!handled) {
|
||||||
})
|
buffer.push(event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
seen.add(event.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribers = [
|
const unsubscribers = [
|
||||||
on(repository, "update", ({added, removed}) => {
|
on(repository, "update", ({added, removed}) => {
|
||||||
if (removed.size > 0) {
|
if (removed.size > 0) {
|
||||||
buffer.update($buffer => $buffer.filter(e => !removed.has(e.id)))
|
buffer = buffer.filter(e => !removed.has(e.id))
|
||||||
events.update($events => $events.filter(e => !removed.has(e.id)))
|
events.update($events => $events.filter(e => !removed.has(e.id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,24 +112,56 @@ export const makeFeed = ({
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
const ctrl = makeFeedController({
|
const loadTimeframe = (since: number, until: number) => {
|
||||||
useWindowing: true,
|
request({
|
||||||
signal: controller.signal,
|
relays: [url],
|
||||||
feed: makeIntersectionFeed(makeRelayFeed(url), feedFromFilters(filters)),
|
autoClose: true,
|
||||||
onExhausted,
|
signal: controller.signal,
|
||||||
})
|
filters: filters.map(filter => ({...filter, since, until})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const scroller = createScroller({
|
const backwardScroller = createScroller({
|
||||||
element,
|
element,
|
||||||
delay: 300,
|
delay: 300,
|
||||||
threshold: 10_000,
|
threshold: 5000,
|
||||||
onScroll: async () => {
|
onScroll: () => {
|
||||||
const $buffer = get(buffer)
|
const [since, until] = backwardWindow
|
||||||
|
|
||||||
events.update($events => [...$events, ...$buffer.splice(0, 30)])
|
backwardWindow = [since - interval, since]
|
||||||
|
|
||||||
if ($buffer.length < 100) {
|
for (const event of buffer.splice(0)) {
|
||||||
ctrl.load(100)
|
insertEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (until > now() - int(2, YEAR)) {
|
||||||
|
loadTimeframe(since, until)
|
||||||
|
} else if (!buffer.some(e => e.created_at < at)) {
|
||||||
|
backwardScroller.stop()
|
||||||
|
onBackwardExhausted?.()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwardScroller = createScroller({
|
||||||
|
element,
|
||||||
|
reverse: true,
|
||||||
|
delay: 300,
|
||||||
|
threshold: 5000,
|
||||||
|
onScroll: () => {
|
||||||
|
const [since, until] = forwardWindow
|
||||||
|
|
||||||
|
forwardWindow = [until, until + interval]
|
||||||
|
|
||||||
|
for (const event of buffer.splice(0)) {
|
||||||
|
insertEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (until < now()) {
|
||||||
|
loadTimeframe(since, until)
|
||||||
|
} else if (!buffer.some(e => e.created_at > at)) {
|
||||||
|
forwardScroller.stop()
|
||||||
|
onForwardExhausted?.()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -134,8 +173,9 @@ export const makeFeed = ({
|
|||||||
return {
|
return {
|
||||||
events,
|
events,
|
||||||
cleanup: () => {
|
cleanup: () => {
|
||||||
scroller.stop()
|
|
||||||
controller.abort()
|
controller.abort()
|
||||||
|
forwardScroller.stop()
|
||||||
|
backwardScroller.stop()
|
||||||
unsubscribers.forEach(call)
|
unsubscribers.forEach(call)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,11 +186,20 @@
|
|||||||
const scrollToNewMessages = () =>
|
const scrollToNewMessages = () =>
|
||||||
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
|
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
|
||||||
|
|
||||||
const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"})
|
const scrollToBottom = () => {
|
||||||
|
if ($page.url.searchParams.get("at")) {
|
||||||
|
at = now()
|
||||||
|
start()
|
||||||
|
} else {
|
||||||
|
element?.scrollTo({top: 0, behavior: "smooth"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let joining = $state(false)
|
let joining = $state(false)
|
||||||
let leaving = $state(false)
|
let leaving = $state(false)
|
||||||
let loadingEvents = $state(true)
|
let loadingBackward = $state(true)
|
||||||
|
let loadingForward = $state(true)
|
||||||
|
let at = $state(parseInt($page.url.searchParams.get("at") || String(now())))
|
||||||
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
||||||
let parent: TrustedEvent | undefined = $state()
|
let parent: TrustedEvent | undefined = $state()
|
||||||
let element: HTMLElement | undefined = $state()
|
let element: HTMLElement | undefined = $state()
|
||||||
@@ -221,7 +230,7 @@
|
|||||||
const adjustedLastChecked =
|
const adjustedLastChecked =
|
||||||
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
|
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
|
||||||
|
|
||||||
for (const event of $events.toReversed()) {
|
for (const event of $events) {
|
||||||
if (seen.has(event.id)) {
|
if (seen.has(event.id)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -272,11 +281,15 @@
|
|||||||
cleanup?.()
|
cleanup?.()
|
||||||
|
|
||||||
const feed = makeFeed({
|
const feed = makeFeed({
|
||||||
|
at,
|
||||||
url,
|
url,
|
||||||
element: element!,
|
element: element!,
|
||||||
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}],
|
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}],
|
||||||
onExhausted: () => {
|
onBackwardExhausted: () => {
|
||||||
loadingEvents = false
|
loadingBackward = false
|
||||||
|
},
|
||||||
|
onForwardExhausted: () => {
|
||||||
|
loadingForward = false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -319,14 +332,11 @@
|
|||||||
start()
|
start()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cleanup()
|
||||||
observer.unobserve(chatCompose!)
|
observer.unobserve(chatCompose!)
|
||||||
observer.unobserve(dynamicPadding!)
|
observer.unobserve(dynamicPadding!)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
cleanup?.()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageBar>
|
<PageBar>
|
||||||
@@ -373,6 +383,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if loadingForward}
|
||||||
|
<p class="py-20 flex justify-center">
|
||||||
|
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{#each elements as { type, id, value, showPubkey } (id)}
|
{#each elements as { type, id, value, showPubkey } (id)}
|
||||||
{#if type === "new-messages"}
|
{#if type === "new-messages"}
|
||||||
<div
|
<div
|
||||||
@@ -405,8 +420,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<p class="flex h-10 items-center justify-center py-20">
|
<p class="flex h-10 items-center justify-center py-20">
|
||||||
{#if loadingEvents}
|
{#if loadingBackward}
|
||||||
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
|
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
|
||||||
{:else}
|
{:else}
|
||||||
<Spinner>End of message history</Spinner>
|
<Spinner>End of message history</Spinner>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -124,9 +124,18 @@
|
|||||||
const scrollToNewMessages = () =>
|
const scrollToNewMessages = () =>
|
||||||
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
|
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
|
||||||
|
|
||||||
const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"})
|
const scrollToBottom = () => {
|
||||||
|
if ($page.url.searchParams.get("at")) {
|
||||||
|
at = now()
|
||||||
|
start()
|
||||||
|
} else {
|
||||||
|
element?.scrollTo({top: 0, behavior: "smooth"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let loadingEvents = $state(true)
|
let loadingBackward = $state(true)
|
||||||
|
let loadingForward = $state(true)
|
||||||
|
let at = $state(parseInt($page.url.searchParams.get("at") || String(now())))
|
||||||
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
||||||
let parent: TrustedEvent | undefined = $state()
|
let parent: TrustedEvent | undefined = $state()
|
||||||
let element: HTMLElement | undefined = $state()
|
let element: HTMLElement | undefined = $state()
|
||||||
@@ -157,7 +166,7 @@
|
|||||||
const adjustedLastChecked =
|
const adjustedLastChecked =
|
||||||
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
|
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
|
||||||
|
|
||||||
for (const event of $events.toReversed()) {
|
for (const event of $events) {
|
||||||
if (seen.has(event.id)) {
|
if (seen.has(event.id)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -204,6 +213,26 @@
|
|||||||
return elements
|
return elements
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
cleanup?.()
|
||||||
|
|
||||||
|
const feed = makeFeed({
|
||||||
|
at,
|
||||||
|
url,
|
||||||
|
element: element!,
|
||||||
|
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}],
|
||||||
|
onBackwardExhausted: () => {
|
||||||
|
loadingBackward = false
|
||||||
|
},
|
||||||
|
onForwardExhausted: () => {
|
||||||
|
loadingForward = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
events = feed.events
|
||||||
|
cleanup = feed.cleanup
|
||||||
|
}
|
||||||
|
|
||||||
const onEscape = () => {
|
const onEscape = () => {
|
||||||
clearParent()
|
clearParent()
|
||||||
clearShare()
|
clearShare()
|
||||||
@@ -238,29 +267,13 @@
|
|||||||
|
|
||||||
observer.observe(chatCompose!)
|
observer.observe(chatCompose!)
|
||||||
observer.observe(dynamicPadding!)
|
observer.observe(dynamicPadding!)
|
||||||
|
start()
|
||||||
const feed = makeFeed({
|
|
||||||
url,
|
|
||||||
element: element!,
|
|
||||||
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}],
|
|
||||||
onExhausted: () => {
|
|
||||||
loadingEvents = false
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
events = feed.events
|
|
||||||
cleanup = feed.cleanup
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cleanup()
|
cleanup()
|
||||||
controller.abort()
|
controller.abort()
|
||||||
observer.unobserve(chatCompose!)
|
observer.unobserve(chatCompose!)
|
||||||
observer.unobserve(dynamicPadding!)
|
observer.unobserve(dynamicPadding!)
|
||||||
|
|
||||||
// Sveltekit calls onDestroy at the beginning of the page load for some reason
|
|
||||||
setTimeout(() => {
|
|
||||||
setChecked($page.url.pathname)
|
|
||||||
}, 800)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -284,6 +297,11 @@
|
|||||||
|
|
||||||
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
|
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
|
||||||
<div bind:this={dynamicPadding}></div>
|
<div bind:this={dynamicPadding}></div>
|
||||||
|
{#if loadingForward}
|
||||||
|
<p class="py-20 flex justify-center">
|
||||||
|
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{#each elements as { type, id, value, showPubkey } (id)}
|
{#each elements as { type, id, value, showPubkey } (id)}
|
||||||
{#if type === "new-messages"}
|
{#if type === "new-messages"}
|
||||||
<div
|
<div
|
||||||
@@ -316,8 +334,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<p class="flex h-10 items-center justify-center py-20">
|
<p class="flex h-10 items-center justify-center py-20">
|
||||||
{#if loadingEvents}
|
{#if loadingBackward}
|
||||||
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
|
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
|
||||||
{:else}
|
{:else}
|
||||||
<Spinner>End of message history</Spinner>
|
<Spinner>End of message history</Spinner>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
url,
|
url,
|
||||||
element: element!,
|
element: element!,
|
||||||
filters: [{kinds: [CLASSIFIED]}, makeCommentFilter([CLASSIFIED])],
|
filters: [{kinds: [CLASSIFIED]}, makeCommentFilter([CLASSIFIED])],
|
||||||
onExhausted: () => {
|
onBackwardExhausted: () => {
|
||||||
loading = false
|
loading = false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
url,
|
url,
|
||||||
element: element!,
|
element: element!,
|
||||||
filters: [{kinds: [ZAP_GOAL]}, makeCommentFilter([ZAP_GOAL])],
|
filters: [{kinds: [ZAP_GOAL]}, makeCommentFilter([ZAP_GOAL])],
|
||||||
onExhausted: () => {
|
onBackwardExhausted: () => {
|
||||||
loading = false
|
loading = false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
url,
|
url,
|
||||||
element: element!,
|
element: element!,
|
||||||
filters: [{kinds: [THREAD]}, makeCommentFilter([THREAD])],
|
filters: [{kinds: [THREAD]}, makeCommentFilter([THREAD])],
|
||||||
onExhausted: () => {
|
onBackwardExhausted: () => {
|
||||||
loading = false
|
loading = false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user