Files
indiekit-endpoint-activitypub/lib/controllers/api-timeline.js
Ricardo af2f899073 refactor: unify reader and explore processing pipeline (Release 0)
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
2026-03-03 12:48:40 +01:00

138 lines
4.2 KiB
JavaScript

/**
* JSON API timeline endpoint — returns pre-rendered HTML cards for infinite scroll AJAX loads.
*/
import { getTimelineItems, countNewItems, markItemsRead } from "../storage/timeline.js";
import { getToken, validateToken } from "../csrf.js";
import { postProcessItems, applyTabFilter, loadModerationData, renderItemCards } from "../item-processing.js";
export function apiTimelineController(mountPath) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const collections = {
ap_timeline: application?.collections?.get("ap_timeline"),
};
// Query parameters
const tab = request.query.tab || "notes";
const tag = typeof request.query.tag === "string" ? request.query.tag.trim() : "";
const before = request.query.before;
const limit = 20;
// Build storage query options
const unread = request.query.unread === "1";
const options = { before, limit, unread };
if (tag) {
options.tag = tag;
} else {
if (tab === "notes") {
options.type = "note";
options.excludeReplies = true;
} else if (tab === "articles") {
options.type = "article";
} else if (tab === "boosts") {
options.type = "boost";
}
}
const result = await getTimelineItems(collections, options);
// Tab filtering for types not supported by storage layer
const tabFiltered = tag ? result.items : applyTabFilter(result.items, tab);
// Shared processing pipeline: moderation, quote stripping, interactions
const modCollections = {
ap_muted: application?.collections?.get("ap_muted"),
ap_blocked: application?.collections?.get("ap_blocked"),
ap_profile: application?.collections?.get("ap_profile"),
};
const moderation = await loadModerationData(modCollections);
const { items, interactionMap } = await postProcessItems(tabFiltered, {
moderation,
interactionsCol: application?.collections?.get("ap_interactions"),
});
const csrfToken = getToken(request.session);
const html = await renderItemCards(items, request, {
...response.locals,
mountPath,
csrfToken,
interactionMap,
});
response.json({
html,
before: result.before,
});
} catch (error) {
next(error);
}
};
}
/**
* GET /admin/reader/api/timeline/count-new — count items newer than a given date.
*/
export function countNewController() {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const collections = {
ap_timeline: application?.collections?.get("ap_timeline"),
};
const after = request.query.after;
const tab = request.query.tab || "notes";
const options = {};
if (tab === "notes") {
options.type = "note";
options.excludeReplies = true;
} else if (tab === "articles") {
options.type = "article";
} else if (tab === "boosts") {
options.type = "boost";
}
const count = await countNewItems(collections, after, options);
response.json({ count });
} catch (error) {
next(error);
}
};
}
/**
* POST /admin/reader/api/timeline/mark-read — mark items as read by UID array.
*/
export function markReadController() {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({ success: false, error: "Invalid CSRF token" });
}
const { uids } = request.body;
if (!Array.isArray(uids) || uids.length === 0) {
return response.status(400).json({ success: false, error: "Missing uids array" });
}
// Cap batch size to prevent abuse
const batch = uids.slice(0, 100).filter((uid) => typeof uid === "string");
const { application } = request.app.locals;
const collections = {
ap_timeline: application?.collections?.get("ap_timeline"),
};
const updated = await markItemsRead(collections, batch);
response.json({ success: true, updated });
} catch (error) {
next(error);
}
};
}