Files
indiekit-endpoint-activitypub/lib/controllers/post-detail.js
Ricardo c8aa0383b9 feat: wire reply intelligence to frontend — timeline filtering, thread reconstruction, visibility badges
- Filter isContext items and private/direct posts from main timeline, new post count, and unread count
- Post detail: query local replies from ap_timeline before remote fetch, deduplicate, sort chronologically
- Add visibility badge (unlisted/private/direct) on item cards next to timestamp

Confab-Link: http://localhost:8080/sessions/af5f8b45-6b8d-442d-8f25-78c326190709
2026-03-17 13:13:51 +01:00

447 lines
15 KiB
JavaScript

// Post detail controller — view individual AP posts/notes/articles
import { Article, Note, Person, Service, Application } from "@fedify/fedify/vocab";
import { getToken } from "../csrf.js";
import { extractObjectData, extractActorInfo } from "../timeline-store.js";
import { getCached, setCache } from "../lookup-cache.js";
import { fetchAndStoreQuote, stripQuoteReferenceHtml } from "../og-unfurl.js";
import { lookupWithSecurity } from "../lookup-helpers.js";
// Load parent posts (inReplyTo chain) up to maxDepth levels
async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxDepth = 5) {
const parents = [];
let currentUrl = parentUrl;
let depth = 0;
while (currentUrl && depth < maxDepth) {
depth++;
// Check timeline first
let parent = timelineCol
? await timelineCol.findOne({
$or: [{ uid: currentUrl }, { url: currentUrl }],
})
: null;
if (!parent) {
// Fetch via lookupObject
const cached = getCached(currentUrl);
let object = cached;
if (!object) {
try {
object = await lookupWithSecurity(ctx,new URL(currentUrl), {
documentLoader,
});
if (object) {
setCache(currentUrl, object);
}
} catch {
break; // Stop on error
}
}
if (!object || !(object instanceof Note || object instanceof Article)) {
break;
}
try {
parent = await extractObjectData(object);
} catch {
break;
}
}
if (parent) {
parents.unshift(parent); // Add to beginning (chronological order)
currentUrl = parent.inReplyTo; // Continue up the chain
} else {
break;
}
}
return parents;
}
// Load local replies from ap_timeline (items where inReplyTo matches this post)
async function loadLocalReplies(timelineCol, postUrl, postUid, maxReplies = 20) {
if (!timelineCol) return [];
const matchUrls = [postUrl, postUid].filter(Boolean);
if (matchUrls.length === 0) return [];
const localReplies = await timelineCol
.find({ inReplyTo: { $in: matchUrls } })
.sort({ published: 1 })
.limit(maxReplies)
.toArray();
return localReplies;
}
// Load replies collection (best-effort) — merges local + remote
async function loadReplies(object, ctx, documentLoader, timelineCol, maxReplies = 20) {
const postUrl = object?.id?.href || object?.url?.href;
// Start with local replies already in our timeline (from organic inbox delivery
// or reply chain fetching). These are fast and free — no network requests.
const seenUrls = new Set();
const replies = await loadLocalReplies(timelineCol, postUrl, postUrl, maxReplies);
for (const r of replies) {
if (r.uid) seenUrls.add(r.uid);
if (r.url) seenUrls.add(r.url);
}
// Supplement with remote replies collection (may contain items we don't have locally)
if (object && replies.length < maxReplies) {
try {
const repliesCollection = await object.getReplies({ documentLoader });
if (repliesCollection) {
let items = [];
try {
items = await repliesCollection.getItems({ documentLoader });
} catch {
// Remote fetch failed — continue with local replies only
}
for (const replyItem of items.slice(0, maxReplies - replies.length)) {
try {
const replyUrl = replyItem.id?.href || replyItem.url?.href;
if (!replyUrl || seenUrls.has(replyUrl)) continue;
seenUrls.add(replyUrl);
// Check timeline first
let reply = timelineCol
? await timelineCol.findOne({
$or: [{ uid: replyUrl }, { url: replyUrl }],
})
: null;
if (!reply) {
// Extract from the item we already have
if (replyItem instanceof Note || replyItem instanceof Article) {
reply = await extractObjectData(replyItem);
}
}
if (reply) {
replies.push(reply);
}
} catch {
continue; // Skip failed replies
}
}
}
} catch {
// getReplies() failed or not available
}
}
// Sort all replies chronologically
replies.sort((a, b) => {
const dateA = a.published || "";
const dateB = b.published || "";
return dateA < dateB ? -1 : dateA > dateB ? 1 : 0;
});
return replies;
}
// GET /admin/reader/post — Show post detail view
export function postDetailController(mountPath, plugin) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const objectUrl = request.query.url;
if (!objectUrl || typeof objectUrl !== "string") {
return response.status(400).render("error", {
title: "Error",
content: "Missing post URL",
});
}
// Validate URL format
try {
new URL(objectUrl);
} catch {
return response.status(400).render("error", {
title: "Error",
content: "Invalid post URL",
});
}
if (!plugin._federation) {
return response.status(503).render("error", {
title: "Error",
content: "Federation not initialized",
});
}
const timelineCol = application?.collections?.get("ap_timeline");
const interactionsCol =
application?.collections?.get("ap_interactions");
// Check local timeline first (optimization)
let timelineItem = null;
if (timelineCol) {
timelineItem = await timelineCol.findOne({
$or: [{ uid: objectUrl }, { url: objectUrl }],
});
}
let object = null;
// If stored item has no media, re-fetch from Fedify to pick up
// attachments that were missed before the async iteration fix.
const storedHasNoMedia =
timelineItem &&
(!timelineItem.photo || timelineItem.photo.length === 0) &&
(!timelineItem.video || timelineItem.video.length === 0) &&
(!timelineItem.audio || timelineItem.audio.length === 0);
if (!timelineItem || storedHasNoMedia) {
// Not in local timeline — fetch via lookupObject
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
// Check cache first
const cached = getCached(objectUrl);
if (cached) {
object = cached;
} else {
try {
object = await lookupWithSecurity(ctx,new URL(objectUrl), {
documentLoader,
});
if (object) {
setCache(objectUrl, object);
}
} catch (error) {
console.warn(
`[post-detail] lookupObject failed for ${objectUrl}:`,
error.message,
);
}
}
if (!object && !storedHasNoMedia) {
// Truly not found (no local item either)
return response.status(404).render("activitypub-post-detail", {
title: response.locals.__("activitypub.reader.post.title"),
readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
notFound: true, objectUrl, mountPath,
item: null, interactionMap: {}, csrfToken: null,
parentPosts: [], replyPosts: [],
});
}
if (object) {
// If it's an actor (Person, Service, Application), redirect to profile
if (
object instanceof Person ||
object instanceof Service ||
object instanceof Application
) {
return response.redirect(
`${mountPath}/admin/reader/profile?url=${encodeURIComponent(objectUrl)}`,
);
}
// Extract timeline item data from the Fedify object
if (object instanceof Note || object instanceof Article) {
try {
const freshItem = await extractObjectData(object);
// If re-fetch found media that the stored item was missing, update MongoDB
if (storedHasNoMedia && timelineCol) {
const hasMedia =
(freshItem.photo && freshItem.photo.length > 0) ||
(freshItem.video && freshItem.video.length > 0) ||
(freshItem.audio && freshItem.audio.length > 0);
if (hasMedia) {
await timelineCol.updateOne(
{ $or: [{ uid: objectUrl }, { url: objectUrl }] },
{ $set: { photo: freshItem.photo, video: freshItem.video, audio: freshItem.audio } },
).catch(() => {});
}
}
timelineItem = freshItem;
} catch (error) {
// If re-extraction fails but we have a stored item, use it
if (!storedHasNoMedia) {
console.error(`[post-detail] extractObjectData failed for ${objectUrl}:`, error.message);
return response.status(500).render("error", {
title: "Error",
content: "Failed to extract post data",
});
}
// storedHasNoMedia=true means timelineItem still has the stored data
}
} else if (!storedHasNoMedia) {
return response.status(400).render("error", {
title: "Error",
content: "Object is not a viewable post (must be Note or Article)",
});
}
}
// If object is null and storedHasNoMedia, we fall through with the stored timelineItem
}
// Build interaction state for this post
const interactionMap = {};
if (interactionsCol && timelineItem) {
const uid = timelineItem.uid;
const displayUrl = timelineItem.url || timelineItem.originalUrl;
const interactions = await interactionsCol
.find({
$or: [{ objectUrl: uid }, { objectUrl: displayUrl }],
})
.toArray();
for (const interaction of interactions) {
const key = uid;
if (!interactionMap[key]) {
interactionMap[key] = {};
}
interactionMap[key][interaction.type] = true;
}
}
// Load thread (parent chain + replies) with timeout
let parentPosts = [];
let replyPosts = [];
try {
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
const threadPromise = Promise.all([
// Load parent chain
timelineItem.inReplyTo
? loadParentChain(ctx, documentLoader, timelineCol, timelineItem.inReplyTo)
: Promise.resolve([]),
// Load replies (if object is available)
object
? loadReplies(object, ctx, documentLoader, timelineCol)
: Promise.resolve([]),
]);
// 15-second timeout for thread loading
const timeout = new Promise((resolve) =>
setTimeout(() => resolve([[], []]), 15000),
);
[parentPosts, replyPosts] = await Promise.race([threadPromise, timeout]);
} catch (error) {
console.error("[post-detail] Thread loading failed:", error.message);
// Continue with empty thread
}
// On-demand quote enrichment: if item has quoteUrl but no quote data yet
if (timelineItem.quoteUrl && !timelineItem.quote) {
try {
const handle = plugin.options.actor.handle;
const qCtx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const qLoader = await qCtx.getDocumentLoader({ identifier: handle });
const quoteObject = await lookupWithSecurity(qCtx,new URL(timelineItem.quoteUrl), {
documentLoader: qLoader,
});
if (quoteObject) {
const quoteData = await extractObjectData(quoteObject, { documentLoader: qLoader });
// If author photo is empty, try fetching the actor directly
if (!quoteData.author.photo && quoteData.author.url) {
try {
const actor = await lookupWithSecurity(qCtx,new URL(quoteData.author.url), { documentLoader: qLoader });
if (actor) {
const actorInfo = await extractActorInfo(actor, { documentLoader: qLoader });
if (actorInfo.photo) quoteData.author.photo = actorInfo.photo;
}
} catch {
// Actor fetch failed — keep existing author data
}
}
timelineItem.quote = {
url: quoteData.url || quoteData.uid,
uid: quoteData.uid,
author: quoteData.author,
content: quoteData.content,
published: quoteData.published,
name: quoteData.name,
photo: quoteData.photo?.slice(0, 1) || [],
};
// Strip RE: paragraph from parent content
const quoteRef = timelineItem.quoteUrl || timelineItem.quote.url || timelineItem.quote.uid;
if (timelineItem.content?.html && quoteRef) {
timelineItem.content.html = stripQuoteReferenceHtml(
timelineItem.content.html,
quoteRef,
);
}
// Persist for future requests (fire-and-forget)
if (timelineCol) {
const persistUpdate = { $set: { quote: timelineItem.quote } };
if (timelineItem.content?.html) {
persistUpdate.$set["content.html"] = timelineItem.content.html;
}
timelineCol.updateOne(
{ $or: [{ uid: objectUrl }, { url: objectUrl }] },
persistUpdate,
).catch(() => {});
}
}
} catch (error) {
console.warn(`[post-detail] Quote fetch failed for ${objectUrl}:`, error.message);
}
}
// Strip RE: paragraph for items with existing quote data (render-time cleanup)
if (timelineItem.quote && timelineItem.content?.html) {
const quoteRef = timelineItem.quoteUrl || timelineItem.quote.url || timelineItem.quote.uid;
if (quoteRef) {
timelineItem.content.html = stripQuoteReferenceHtml(timelineItem.content.html, quoteRef);
}
}
const csrfToken = getToken(request.session);
response.render("activitypub-post-detail", {
title: response.locals.__("activitypub.reader.post.title"),
readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
item: timelineItem,
interactionMap,
csrfToken,
mountPath,
parentPosts,
replyPosts,
});
} catch (error) {
next(error);
}
};
}