Web: Activity loads older events as you scroll, no Show older button

Preference 172. ScrollSentinel watches the end of a cursor-paged list and
asks for the next page a screen early. It asks once per cursor, keeps going
when a page lands without pushing the end off screen, and stops on a failure
until Try again (an observer re-firing on a failure is a request loop). A
failed older page now gets its own error beside the retry, instead of
replacing the list's load error.

Adapted from fabledstash's ScrollSentinel (snippet 3760).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 16:54:19 -04:00
co-authored by Claude Opus 5.5
parent 962637ea8b
commit eb6a1ff3ef
3 changed files with 177 additions and 14 deletions
+16 -14
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { api } from "../api/client"; import { api } from "../api/client";
import { errorMessage } from "../api/errors"; import { errorMessage } from "../api/errors";
import { formatShortDateTime } from "../notes/datetime"; import { formatShortDateTime } from "../notes/datetime";
import { useLoad } from "../composables/useAction"; import { useLoad } from "../composables/useAction";
import ScrollSentinel from "./ScrollSentinel.vue";
// Admin: what has happened to accounts on this instance, newest first (#2939 §5). // Admin: what has happened to accounts on this instance, newest first (#2939 §5).
// Kept for `audit_retention_days` (Settings → Security). // Kept for `audit_retention_days` (Settings → Security).
@@ -52,6 +53,9 @@ const { loading, error, load } = useLoad(async () => {
more.value = res.more; more.value = res.more;
}, "Couldn't load activity."); }, "Couldn't load activity.");
const loadingMore = ref(false); const loadingMore = ref(false);
const moreError = ref("");
// The next page starts before the oldest event shown; empty once there is none.
const cursor = computed(() => (more.value ? (events.value[events.value.length - 1]?.at ?? "") : ""));
async function page(before?: string) { async function page(before?: string) {
const query = before ? `?before=${encodeURIComponent(before)}` : ""; const query = before ? `?before=${encodeURIComponent(before)}` : "";
@@ -59,15 +63,15 @@ async function page(before?: string) {
} }
async function loadMore() { async function loadMore() {
const last = events.value[events.value.length - 1]; if (!cursor.value || loadingMore.value) return;
if (!last) return;
loadingMore.value = true; loadingMore.value = true;
moreError.value = "";
try { try {
const res = await page(last.at); const res = await page(cursor.value);
events.value.push(...res.events); events.value.push(...res.events);
more.value = res.more; more.value = res.more;
} catch (e) { } catch (e) {
error.value = errorMessage(e, "Couldn't load older activity."); moreError.value = errorMessage(e, "Couldn't load older activity.");
} finally { } finally {
loadingMore.value = false; loadingMore.value = false;
} }
@@ -106,14 +110,12 @@ onMounted(() => {
</li> </li>
</ul> </ul>
<button <ScrollSentinel
v-if="more" v-if="!loading"
type="button" :cursor="cursor"
class="self-start btn-sm" :loading="loadingMore"
:disabled="loadingMore" :error="moreError"
@click="loadMore" @more="loadMore"
> />
Show older
</button>
</section> </section>
</template> </template>
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { nextTick } from "vue";
import ScrollSentinel from "./ScrollSentinel.vue";
// jsdom has no IntersectionObserver. This one reports only when a test says the
// end of the list is (or is not) on screen.
let report: ((visible: boolean) => void) | undefined;
const disconnected = vi.fn();
class StubObserver {
constructor(callback: IntersectionObserverCallback) {
report = (visible) =>
callback([{ isIntersecting: visible } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
}
observe() {}
disconnect = disconnected;
}
beforeEach(() => {
report = undefined;
disconnected.mockClear();
vi.stubGlobal("IntersectionObserver", StubObserver);
});
afterEach(() => vi.unstubAllGlobals());
// The observer attaches in a post-flush watcher, so nothing is watched until a tick
// after mount.
async function sentinel(props: { cursor: string; loading?: boolean; error?: string }) {
const wrapper = mount(ScrollSentinel, { props: { loading: false, ...props } });
await nextTick();
return wrapper;
}
describe("ScrollSentinel", () => {
it("asks for the next page when the end comes near, once per cursor", async () => {
const wrapper = await sentinel({ cursor: "c1" });
expect(wrapper.emitted("more")).toBeUndefined();
report!(true);
report!(true);
expect(wrapper.emitted("more")).toHaveLength(1);
});
it("keeps going when a page lands without pushing the end off screen", async () => {
const wrapper = await sentinel({ cursor: "c1" });
report!(true);
await wrapper.setProps({ loading: true });
await wrapper.setProps({ cursor: "c2", loading: false });
expect(wrapper.emitted("more")).toHaveLength(2);
});
it("waits for a page in flight", async () => {
const wrapper = await sentinel({ cursor: "c1", loading: true });
report!(true);
expect(wrapper.emitted("more")).toBeUndefined();
expect(wrapper.text()).toContain("Loading older…");
});
it("stops on a failure, and only Try again asks again", async () => {
const wrapper = await sentinel({ cursor: "c1" });
report!(true);
await wrapper.setProps({ error: "Couldn't load older activity." });
report?.(true);
expect(wrapper.emitted("more")).toHaveLength(1);
expect(wrapper.text()).toContain("Couldn't load older activity.");
await wrapper.get("button").trigger("click");
expect(wrapper.emitted("more")).toHaveLength(2);
});
it("shows nothing once the list is complete", async () => {
const wrapper = await sentinel({ cursor: "" });
expect(wrapper.html()).not.toContain("<div");
expect(report).toBeUndefined();
});
it("stops watching when it goes", async () => {
const wrapper = await sentinel({ cursor: "c1" });
wrapper.unmount();
expect(disconnected).toHaveBeenCalled();
});
});
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from "vue";
// The end of a cursor-paged list: asks for the next page as the reader nears it,
// in place of a "Show older" button (preference 172). The parent keeps its cursor,
// its in-flight flag and its fetch; this owns the observing.
//
// <ScrollSentinel :cursor="cursor" :loading="loadingMore" :error="moreError" @more="loadMore" />
//
// `cursor` is empty once the list is complete, and then nothing renders.
const props = defineProps<{ cursor: string; loading: boolean; error?: string }>();
const emit = defineEmits<{ more: [] }>();
const sentinel = ref<HTMLElement | null>(null);
const visible = ref(false);
// One request per page. The observer fires on every crossing, and a short last
// page leaves the sentinel on screen for good. Keying on the cursor stops a page
// being asked for twice, and stops a failed one being retried on every scroll,
// since a failure leaves the cursor where it was. Only Try again re-asks.
let asked = "";
function pump() {
if (!visible.value || props.loading || props.error || !props.cursor) return;
if (props.cursor === asked) return;
asked = props.cursor;
emit("more");
}
let observer: IntersectionObserver | undefined;
function observe(el: HTMLElement | null) {
observer?.disconnect();
observer = undefined;
if (!el) {
// Visibility goes with the element, or a list that starts over would fetch
// its second page on the old list's scroll position.
visible.value = false;
return;
}
observer = new IntersectionObserver(
(entries) => {
visible.value = entries.some((e) => e.isIntersecting);
pump();
},
// Extends the watched area a screen below the viewport, so the page is asked
// for before the reader reaches the end rather than when they do.
{ rootMargin: "0px 0px 100% 0px" },
);
observer.observe(el);
}
// flush: "post": the template ref is only set once the DOM is patched.
watch(sentinel, observe, { flush: "post" });
// A page that lands without pushing the end off screen gives the observer nothing
// new to report, so carry on from here or the list stops a page early.
watch([() => props.cursor, () => props.loading], pump);
onBeforeUnmount(() => observer?.disconnect());
</script>
<template>
<div v-if="cursor" role="status" aria-live="polite">
<!-- The one case with a button: an observer re-firing on a failure is a request loop. -->
<div v-if="error" class="flex items-center gap-3">
<p class="form-error">{{ error }}</p>
<button type="button" class="btn-sm" @click="emit('more')">Try again</button>
</div>
<template v-else>
<div ref="sentinel" aria-hidden="true"></div>
<p v-if="loading" class="text-sm text-neutral-400">Loading older…</p>
</template>
</div>
</template>