Files
indiekit-endpoint-activitypub/assets/reader-infinite-scroll.js
Ricardo 508ac75363 feat: new posts banner, mark-as-read on scroll, unread filter
- Poll every 30s for new items, show sticky "N new posts — Load" banner
- IntersectionObserver marks cards as read at 50% visibility, batches to
  server every 5s
- Read cards fade to 70% opacity, full opacity on hover
- "Unread" toggle in tab bar filters to unread-only items
- New API: GET /api/timeline/count-new, POST /api/timeline/mark-read

Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
2026-03-02 10:54:11 +01:00

340 lines
9.4 KiB
JavaScript

/**
* Infinite scroll — AlpineJS component for AJAX load-more on the timeline
* Registers the `apInfiniteScroll` Alpine data component.
*/
document.addEventListener("alpine:init", () => {
// eslint-disable-next-line no-undef
Alpine.data("apExploreScroll", () => ({
loading: false,
done: false,
maxId: null,
instance: "",
scope: "local",
hashtag: "",
observer: null,
init() {
const el = this.$el;
this.maxId = el.dataset.maxId || null;
this.instance = el.dataset.instance || "";
this.scope = el.dataset.scope || "local";
this.hashtag = el.dataset.hashtag || "";
if (!this.maxId) {
this.done = true;
return;
}
this.observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting && !this.loading && !this.done) {
this.loadMore();
}
}
},
{ rootMargin: "200px" }
);
if (this.$refs.sentinel) {
this.observer.observe(this.$refs.sentinel);
}
},
async loadMore() {
if (this.loading || this.done || !this.maxId) return;
this.loading = true;
const timeline = document.getElementById("ap-explore-timeline");
const mountPath = timeline ? timeline.dataset.mountPath : "";
const params = new URLSearchParams({
instance: this.instance,
scope: this.scope,
max_id: this.maxId,
});
// Pass hashtag when in hashtag mode so infinite scroll stays on tag timeline
if (this.hashtag) {
params.set("hashtag", this.hashtag);
}
try {
const res = await fetch(
`${mountPath}/admin/reader/api/explore?${params.toString()}`,
{ headers: { Accept: "application/json" } }
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.html && timeline) {
timeline.insertAdjacentHTML("beforeend", data.html);
}
if (data.maxId) {
this.maxId = data.maxId;
} else {
this.done = true;
if (this.observer) this.observer.disconnect();
}
} catch (err) {
console.error("[ap-explore-scroll] load failed:", err.message);
} finally {
this.loading = false;
}
},
destroy() {
if (this.observer) this.observer.disconnect();
},
}));
// eslint-disable-next-line no-undef
Alpine.data("apInfiniteScroll", () => ({
loading: false,
done: false,
before: null,
tab: "",
tag: "",
observer: null,
init() {
const el = this.$el;
this.before = el.dataset.before || null;
this.tab = el.dataset.tab || "";
this.tag = el.dataset.tag || "";
// Hide the no-JS pagination fallback now that JS is active
const paginationEl =
document.getElementById("ap-reader-pagination") ||
document.getElementById("ap-tag-pagination");
if (paginationEl) {
paginationEl.style.display = "none";
}
if (!this.before) {
this.done = true;
return;
}
// Set up IntersectionObserver to auto-load when sentinel comes into view
this.observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting && !this.loading && !this.done) {
this.loadMore();
}
}
},
{ rootMargin: "200px" }
);
if (this.$refs.sentinel) {
this.observer.observe(this.$refs.sentinel);
}
},
async loadMore() {
if (this.loading || this.done || !this.before) return;
this.loading = true;
const timeline = document.getElementById("ap-timeline");
const mountPath = timeline ? timeline.dataset.mountPath : "";
const params = new URLSearchParams({ before: this.before });
if (this.tab) params.set("tab", this.tab);
if (this.tag) params.set("tag", this.tag);
try {
const res = await fetch(
`${mountPath}/admin/reader/api/timeline?${params.toString()}`,
{ headers: { Accept: "application/json" } }
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.html && timeline) {
// Append the returned pre-rendered HTML
timeline.insertAdjacentHTML("beforeend", data.html);
}
if (data.before) {
this.before = data.before;
} else {
// No more items
this.done = true;
if (this.observer) this.observer.disconnect();
}
} catch (err) {
console.error("[ap-infinite-scroll] load failed:", err.message);
} finally {
this.loading = false;
}
},
appendItems(/* detail */) {
// Custom event hook — not used in this implementation but kept for extensibility
},
destroy() {
if (this.observer) this.observer.disconnect();
},
}));
/**
* New posts banner — polls for new items every 30s, shows "N new posts" banner.
*/
// eslint-disable-next-line no-undef
Alpine.data("apNewPostsBanner", () => ({
count: 0,
newest: null,
tab: "",
mountPath: "",
_interval: null,
init() {
const el = this.$el;
this.newest = el.dataset.newest || null;
this.tab = el.dataset.tab || "notes";
this.mountPath = el.dataset.mountPath || "";
if (!this.newest) return;
this._interval = setInterval(() => this.poll(), 30000);
},
async poll() {
if (!this.newest) return;
try {
const params = new URLSearchParams({ after: this.newest, tab: this.tab });
const res = await fetch(
`${this.mountPath}/admin/reader/api/timeline/count-new?${params}`,
{ headers: { Accept: "application/json" } },
);
if (!res.ok) return;
const data = await res.json();
this.count = data.count || 0;
} catch {
// Silently ignore polling errors
}
},
async loadNew() {
if (!this.newest || this.count === 0) return;
try {
const params = new URLSearchParams({ after: this.newest, tab: this.tab });
const res = await fetch(
`${this.mountPath}/admin/reader/api/timeline?${params}`,
{ headers: { Accept: "application/json" } },
);
if (!res.ok) return;
const data = await res.json();
const timeline = document.getElementById("ap-timeline");
if (data.html && timeline) {
timeline.insertAdjacentHTML("afterbegin", data.html);
// Update newest cursor to the first item's published date
const firstCard = timeline.querySelector(".ap-card");
if (firstCard) {
const timeEl = firstCard.querySelector("time[datetime]");
if (timeEl) this.newest = timeEl.getAttribute("datetime");
}
}
this.count = 0;
} catch {
// Silently ignore load errors
}
},
destroy() {
if (this._interval) clearInterval(this._interval);
},
}));
/**
* Read tracking — IntersectionObserver marks cards as read on 50% visibility.
* Batches UIDs and flushes to server every 5 seconds.
*/
// eslint-disable-next-line no-undef
Alpine.data("apReadTracker", () => ({
_observer: null,
_batch: [],
_flushTimer: null,
_mountPath: "",
_csrfToken: "",
init() {
const el = this.$el;
this._mountPath = el.dataset.mountPath || "";
this._csrfToken = el.dataset.csrfToken || "";
this._observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const card = entry.target;
const uid = card.dataset.uid;
if (uid && !card.classList.contains("ap-card--read")) {
card.classList.add("ap-card--read");
this._batch.push(uid);
}
this._observer.unobserve(card);
}
}
},
{ threshold: 0.5 },
);
// Observe all existing cards
this._observeCards();
// Watch for new cards added by infinite scroll
this._mutationObserver = new MutationObserver(() => this._observeCards());
this._mutationObserver.observe(el, { childList: true, subtree: true });
// Flush batch every 5 seconds
this._flushTimer = setInterval(() => this._flush(), 5000);
},
_observeCards() {
const cards = this.$el.querySelectorAll(".ap-card[data-uid]:not(.ap-card--read)");
for (const card of cards) {
this._observer.observe(card);
}
},
async _flush() {
if (this._batch.length === 0) return;
const uids = [...this._batch];
this._batch = [];
try {
await fetch(`${this._mountPath}/admin/reader/api/timeline/mark-read`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": this._csrfToken,
},
body: JSON.stringify({ uids }),
});
} catch {
// Non-critical — items will be re-marked on next view
}
},
destroy() {
if (this._observer) this._observer.disconnect();
if (this._mutationObserver) this._mutationObserver.disconnect();
if (this._flushTimer) clearInterval(this._flushTimer);
this._flush(); // Final flush on teardown
},
}));
});