Files
Sven Giersig eefa46f0c1 Merge upstream rmdes:main — v2.10.0 (Delete, visibility, CW, polls, Flag) into svemagie/main (v2.10.1)
Upstream v2.10.0 adds: outbound Delete, visibility addressing (unlisted/
followers-only), Content Warning (sensitive flag + summary), inbound poll
rendering, Flag/report handler, DM support files.

Conflict resolution — all four conflicts were additive (no code removed):

  lib/controllers/reader.js: union of validTabs — fork added "mention",
    upstream added "dm" and "report"; result keeps all five additions.

  lib/storage/notifications.js: union of count keys — fork added mention:0,
    upstream added dm:0 and report:0; result keeps the fork's mention split
    logic alongside the new upstream keys.

  views/partials/ap-notification-card.njk: fork kept isDirect 🔒 badge for
    direct mentions; upstream added ✉ for dm and ⚑ for report; result keeps
    the isDirect branch and appends the two new type badges.

  package.json: upstream bumped to 2.10.0; we bump to 2.10.1 to reflect our
    own Alpine.js and publication-aware docloader bug fixes on top.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 13:00:58 +01:00

304 lines
9.3 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", "dm", "report"];
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);
}
};
}