Files
indiekit-endpoint-activitypub/lib/controllers/reader.js
2026-03-13 07:36:09 +01:00

304 lines
9.2 KiB
JavaScript

/**
* Reader controller — shows timeline of posts from followed accounts.
*/
import { getTimelineItems, countUnreadItems } from "../storage/timeline.js";
import {
getNotifications,
getDirectConversations,
getUnreadNotificationCount,
getNotificationCountsByType,
markAllNotificationsRead,
clearAllNotifications,
deleteNotification,
} from "../storage/notifications.js";
import { getToken, validateToken } from "../csrf.js";
import { getFollowedTags } from "../storage/followed-tags.js";
import { postProcessItems, applyTabFilter, loadModerationData } from "../item-processing.js";
// Re-export controllers from split modules for backward compatibility
export {
composeController,
submitComposeController,
} from "./compose.js";
export {
remoteProfileController,
followController,
unfollowController,
} from "./profile.remote.js";
export { postDetailController } from "./post-detail.js";
export function readerController(mountPath) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const collections = {
ap_timeline: application?.collections?.get("ap_timeline"),
ap_notifications: application?.collections?.get("ap_notifications"),
ap_followed_tags: application?.collections?.get("ap_followed_tags"),
};
// Query parameters
const tab = request.query.tab || "notes";
const before = request.query.before;
const after = request.query.after;
const limit = Number.parseInt(request.query.limit || "20", 10);
// Unread filter
const unread = request.query.unread === "1";
// Direct messages tab — conversation view
if (tab === "direct") {
const csrfToken = getToken(request.session);
const [conversations, unreadCount] = await Promise.all([
getDirectConversations(collections),
getUnreadNotificationCount(collections),
]);
return response.render("activitypub-reader", {
title: response.locals.__("activitypub.reader.title"),
readerParent: { href: mountPath, text: response.locals.__("activitypub.title") },
conversations,
items: [],
tab,
unread: false,
before: null,
after: null,
unreadCount,
unreadTimelineCount: 0,
interactionMap: {},
csrfToken,
mountPath,
followedTags: [],
});
}
// Build query options
const options = { before, after, limit, unread };
// Tab filtering at storage level
if (tab === "notes") {
options.type = "note";
options.excludeReplies = true;
} else if (tab === "articles") {
options.type = "article";
} else if (tab === "boosts") {
options.type = "boost";
}
// Get timeline items
const result = await getTimelineItems(collections, options);
// Tab filtering for types not supported by storage layer
const tabFiltered = applyTabFilter(result.items, tab);
// Load moderation data + interactions, apply shared pipeline
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"),
});
// Get unread notification count for badge + unread timeline count for toggle
const [unreadCount, unreadTimelineCount] = await Promise.all([
getUnreadNotificationCount(collections),
countUnreadItems(collections),
]);
// CSRF token for interaction forms
const csrfToken = getToken(request.session);
// Followed tags for sidebar
let followedTags = [];
try {
followedTags = await getFollowedTags(collections);
} catch {
// Non-critical — collection may not exist yet
}
response.render("activitypub-reader", {
title: response.locals.__("activitypub.reader.title"),
readerParent: { href: mountPath, text: response.locals.__("activitypub.title") },
items,
tab,
unread,
before: result.before,
after: result.after,
unreadCount,
unreadTimelineCount,
interactionMap,
csrfToken,
mountPath,
followedTags,
});
} catch (error) {
next(error);
}
};
}
export function notificationsController(mountPath) {
const validTabs = ["all", "reply", "mention", "like", "boost", "follow"];
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const collections = {
ap_notifications: application?.collections?.get("ap_notifications"),
};
const tab = validTabs.includes(request.query.tab)
? request.query.tab
: "reply";
const before = request.query.before;
const limit = Number.parseInt(request.query.limit || "20", 10);
// Build query options with type filter
const options = { before, limit };
if (tab !== "all") {
options.type = tab;
}
// CSRF token for action forms
const csrfToken = getToken(request.session);
// Direct messages tab uses conversation grouping instead of flat list
if (tab === "mention") {
const [conversations, unreadCount, tabCounts] = await Promise.all([
getDirectConversations(collections),
getUnreadNotificationCount(collections),
getNotificationCountsByType(collections),
]);
return response.render("activitypub-notifications", {
title: response.locals.__("activitypub.notifications.title"),
readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
conversations,
items: [],
before: null,
tab,
tabCounts,
unreadCount,
csrfToken,
mountPath,
});
}
// Get filtered notifications + counts in parallel
const [result, unreadCount, tabCounts] = await Promise.all([
getNotifications(collections, options),
getUnreadNotificationCount(collections),
getNotificationCountsByType(collections),
]);
response.render("activitypub-notifications", {
title: response.locals.__("activitypub.notifications.title"),
readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
items: result.items,
before: result.before,
tab,
tabCounts,
unreadCount,
csrfToken,
mountPath,
});
} catch (error) {
next(error);
}
};
}
/**
* POST /admin/reader/notifications/mark-read — mark all notifications as read.
*/
export function markAllNotificationsReadController(mountPath) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).redirect(`${mountPath}/admin/reader/notifications`);
}
const { application } = request.app.locals;
const collections = {
ap_notifications: application?.collections?.get("ap_notifications"),
};
await markAllNotificationsRead(collections);
return response.redirect(`${mountPath}/admin/reader/notifications`);
} catch (error) {
next(error);
}
};
}
/**
* POST /admin/reader/notifications/clear — delete all notifications.
*/
export function clearAllNotificationsController(mountPath) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).redirect(`${mountPath}/admin/reader/notifications`);
}
const { application } = request.app.locals;
const collections = {
ap_notifications: application?.collections?.get("ap_notifications"),
};
await clearAllNotifications(collections);
return response.redirect(`${mountPath}/admin/reader/notifications`);
} catch (error) {
next(error);
}
};
}
/**
* POST /admin/reader/notifications/delete — delete a single notification.
*/
export function deleteNotificationController(mountPath) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { uid } = request.body;
if (!uid) {
return response.status(400).json({
success: false,
error: "Missing notification UID",
});
}
const { application } = request.app.locals;
const collections = {
ap_notifications: application?.collections?.get("ap_notifications"),
};
await deleteNotification(collections, uid);
// Support both JSON (fetch) and form redirect
if (request.headers.accept?.includes("application/json")) {
return response.json({ success: true, uid });
}
return response.redirect(`${mountPath}/admin/reader/notifications`);
} catch (error) {
next(error);
}
};
}