mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
Extract shared item-processing.js module with postProcessItems(), applyModerationFilters(), buildInteractionMap(), applyTabFilter(), renderItemCards(), and loadModerationData(). All controllers (reader, api-timeline, explore, hashtag-explore, tag-timeline) now flow through the same pipeline. Unify Alpine.js infinite scroll into single parameterized apInfiniteScroll component configured via data attributes, replacing the separate apExploreScroll component. Also adds fetchAndStoreQuote() for quote enrichment and on-demand quote fetching in post-detail controller. Bump version to 2.5.0. Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
272 lines
8.1 KiB
JavaScript
272 lines
8.1 KiB
JavaScript
/**
|
|
* Infinite scroll — unified AlpineJS component for AJAX load-more.
|
|
* Works for both reader timeline and explore view via data attributes.
|
|
*
|
|
* Required data attributes on the component element:
|
|
* data-cursor — initial pagination cursor value
|
|
* data-api-url — API endpoint URL (e.g., /activitypub/admin/reader/api/timeline)
|
|
* data-cursor-param — query param name for the cursor (e.g., "before" or "max_id")
|
|
* data-cursor-field — response JSON field for the next cursor (e.g., "before" or "maxId")
|
|
* data-timeline-id — DOM ID of the timeline container to append HTML into
|
|
*
|
|
* Optional:
|
|
* data-extra-params — JSON-encoded object of additional query params
|
|
* data-hide-pagination — CSS selector of no-JS pagination to hide
|
|
*/
|
|
|
|
document.addEventListener("alpine:init", () => {
|
|
// eslint-disable-next-line no-undef
|
|
Alpine.data("apInfiniteScroll", () => ({
|
|
loading: false,
|
|
done: false,
|
|
cursor: null,
|
|
apiUrl: "",
|
|
cursorParam: "before",
|
|
cursorField: "before",
|
|
timelineId: "",
|
|
extraParams: {},
|
|
observer: null,
|
|
|
|
init() {
|
|
const el = this.$el;
|
|
this.cursor = el.dataset.cursor || null;
|
|
this.apiUrl = el.dataset.apiUrl || "";
|
|
this.cursorParam = el.dataset.cursorParam || "before";
|
|
this.cursorField = el.dataset.cursorField || "before";
|
|
this.timelineId = el.dataset.timelineId || "";
|
|
|
|
// Parse extra params from JSON data attribute
|
|
try {
|
|
this.extraParams = JSON.parse(el.dataset.extraParams || "{}");
|
|
} catch {
|
|
this.extraParams = {};
|
|
}
|
|
|
|
// Hide the no-JS pagination fallback now that JS is active
|
|
const hideSel = el.dataset.hidePagination;
|
|
if (hideSel) {
|
|
const paginationEl = document.getElementById(hideSel);
|
|
if (paginationEl) paginationEl.style.display = "none";
|
|
}
|
|
|
|
if (!this.cursor) {
|
|
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.cursor) return;
|
|
|
|
this.loading = true;
|
|
|
|
const params = new URLSearchParams({
|
|
[this.cursorParam]: this.cursor,
|
|
...this.extraParams,
|
|
});
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`${this.apiUrl}?${params.toString()}`,
|
|
{ headers: { Accept: "application/json" } },
|
|
);
|
|
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
|
|
const data = await res.json();
|
|
|
|
const timeline = this.timelineId
|
|
? document.getElementById(this.timelineId)
|
|
: null;
|
|
|
|
if (data.html && timeline) {
|
|
timeline.insertAdjacentHTML("beforeend", data.html);
|
|
}
|
|
|
|
if (data[this.cursorField]) {
|
|
this.cursor = data[this.cursorField];
|
|
} else {
|
|
this.done = true;
|
|
if (this.observer) this.observer.disconnect();
|
|
}
|
|
} catch (err) {
|
|
console.error("[ap-infinite-scroll] load failed:", err.message);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
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")) {
|
|
// Mark read server-side but DON'T dim visually in this session.
|
|
// Cards only appear dimmed when they arrive from the server
|
|
// with item.read=true on a subsequent page load.
|
|
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
|
|
},
|
|
}));
|
|
});
|