mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
304 lines
9.2 KiB
JavaScript
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);
|
|
}
|
|
};
|
|
}
|