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