Files
indiekit-endpoint-activitypub/lib/controllers/my-profile.js
Ricardo 5c2fd09f8f fix: normalize category to array in my-profile controller
When a post has a single category, Indiekit stores it as a string
(e.g. "Fraude") rather than an array. Nunjucks iterates strings
character by character, producing hashtag pills like #F #r #a #u #d #e.
2026-02-28 18:21:32 +01:00

246 lines
7.1 KiB
JavaScript

/**
* My Profile controller — admin view of own profile and outbound activity.
* Shows profile header + tabbed activity (posts, replies, likes, boosts).
*/
import { getToken } from "../csrf.js";
const VALID_TABS = ["posts", "replies", "likes", "boosts"];
const PAGE_LIMIT = 20;
/**
* Normalize a JF2 post from the Indiekit `posts` collection into the
* shape expected by the ap-item-card.njk partial.
*/
function postToCardItem(post, profile) {
const props = post.properties || {};
const contentProp = props.content;
const content =
typeof contentProp === "string" ? { text: contentProp } : contentProp || {};
// Normalize photo to array of { url } objects
let photo = [];
if (props.photo) {
const photos = Array.isArray(props.photo) ? props.photo : [props.photo];
photo = photos.map((p) => (typeof p === "string" ? { url: p } : p));
}
return {
uid: props.url,
url: props.url,
name: props.name || "",
content,
published: props.published,
type: props["post-type"] || "note",
author: {
name: profile?.name || "",
url: profile?.url || "",
photo: profile?.icon || "",
},
photo,
category: Array.isArray(props.category)
? props.category
: props.category
? [props.category]
: [],
};
}
/**
* Enrich interaction records (likes/boosts) with timeline data.
* Returns card items sorted by interaction date.
*/
async function enrichInteractions(interactions, apTimeline) {
if (!interactions.length) return [];
const urls = interactions.map((i) => i.objectUrl);
const timelinePosts = apTimeline
? await apTimeline.find({ uid: { $in: urls } }).toArray()
: [];
const postMap = new Map(timelinePosts.map((p) => [p.uid, p]));
return interactions.map((interaction) => {
const post = postMap.get(interaction.objectUrl);
if (post) {
return {
...post,
published:
post.published instanceof Date
? post.published.toISOString()
: post.published,
_interactionDate: interaction.createdAt,
};
}
// Fallback: minimal card with just the URL
return {
uid: interaction.objectUrl,
url: interaction.objectUrl,
content: { text: interaction.objectUrl },
published: interaction.createdAt,
type: "note",
author: { name: "", url: "", photo: "" },
};
});
}
export function myProfileController(plugin) {
const mountPath = plugin.options.mountPath;
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const collections = application.collections;
const tab = VALID_TABS.includes(request.query.tab)
? request.query.tab
: "posts";
const before = request.query.before;
// Profile header data (parallel)
const apProfile = collections.get("ap_profile");
const apFollowers = collections.get("ap_followers");
const apFollowing = collections.get("ap_following");
const postsCollection = collections.get("posts");
const [profile, followerCount, followingCount, postCount] =
await Promise.all([
apProfile ? apProfile.findOne({}) : null,
apFollowers ? apFollowers.countDocuments() : 0,
apFollowing ? apFollowing.countDocuments() : 0,
postsCollection ? postsCollection.countDocuments() : 0,
]);
const domain = new URL(plugin._publicationUrl).hostname;
const handle = plugin.options.actor.handle;
// Tab data
let items = [];
let nextBefore = null;
switch (tab) {
case "posts": {
const query = {};
if (before) {
query["properties.published"] = { $lt: before };
}
const posts = postsCollection
? await postsCollection
.find(query)
.sort({ "properties.published": -1 })
.limit(PAGE_LIMIT)
.toArray()
: [];
items = posts.map((p) => postToCardItem(p, profile));
if (posts.length === PAGE_LIMIT) {
nextBefore = items[items.length - 1].published;
}
break;
}
case "replies": {
// Query posts collection for reply-type posts (have in-reply-to)
if (postsCollection) {
const query = {
"properties.post-type": "reply",
};
if (before) {
query["properties.published"] = { $lt: before };
}
const replies = await postsCollection
.find(query)
.sort({ "properties.published": -1 })
.limit(PAGE_LIMIT)
.toArray();
items = replies.map((p) => {
const card = postToCardItem(p, profile);
card.inReplyTo = p.properties?.["in-reply-to"] || null;
card.type = "reply";
return card;
});
if (replies.length === PAGE_LIMIT) {
nextBefore = items[items.length - 1].published;
}
}
break;
}
case "likes": {
const apInteractions = collections.get("ap_interactions");
const apTimeline = collections.get("ap_timeline");
if (apInteractions) {
const query = { type: "like" };
if (before) {
query.createdAt = { $lt: before };
}
const likes = await apInteractions
.find(query)
.sort({ createdAt: -1 })
.limit(PAGE_LIMIT)
.toArray();
items = await enrichInteractions(likes, apTimeline);
if (likes.length === PAGE_LIMIT) {
nextBefore = likes[likes.length - 1].createdAt;
}
}
break;
}
case "boosts": {
const apInteractions = collections.get("ap_interactions");
const apTimeline = collections.get("ap_timeline");
if (apInteractions) {
const query = { type: "boost" };
if (before) {
query.createdAt = { $lt: before };
}
const boosts = await apInteractions
.find(query)
.sort({ createdAt: -1 })
.limit(PAGE_LIMIT)
.toArray();
items = await enrichInteractions(boosts, apTimeline);
if (boosts.length === PAGE_LIMIT) {
nextBefore = boosts[boosts.length - 1].createdAt;
}
}
break;
}
}
const csrfToken = getToken(request.session);
response.render("activitypub-my-profile", {
title: response.locals.__("activitypub.myProfile.title"),
readerParent: { href: mountPath, text: response.locals.__("activitypub.title") },
profile: profile || {},
handle,
domain,
fullHandle: `@${handle}@${domain}`,
followerCount,
followingCount,
postCount,
tab,
items,
before: nextBefore,
csrfToken,
interactionMap: {},
mountPath,
});
} catch (error) {
next(error);
}
};
}