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:
@@ -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>
|
||||||
Reference in New Issue
Block a user