From 743cb6b85b93b65d89735c0d3646dd15887af714 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Mon, 23 Feb 2026 15:55:44 +0100 Subject: [PATCH] feat: notification tabs, my-profile page, clickable timestamps, quick-reply - Notification view: tab navigation (Replies, Likes, Boosts, Follows, All) with count badges; defaults to Replies tab; type filter in storage layer with compound index for efficient queries - My Profile admin page: profile header with avatar/stats/bio, tabbed activity view (Posts, Replies, Likes, Boosts) pulling from posts, ap_activities, and ap_interactions collections - Reader: default tab changed from All to Notes - Timeline cards: timestamps now link to post detail view - Notification cards: Reply and View Thread buttons on reply/mention types --- assets/reader.css | 237 ++++++++++++++++++++++ index.js | 11 ++ lib/controllers/my-profile.js | 251 ++++++++++++++++++++++++ lib/controllers/reader.js | 24 ++- lib/storage/notifications.js | 41 ++++ locales/en.json | 23 ++- package.json | 2 +- views/activitypub-my-profile.njk | 85 ++++++++ views/activitypub-notifications.njk | 58 ++++-- views/partials/ap-item-card.njk | 8 +- views/partials/ap-notification-card.njk | 11 ++ 11 files changed, 726 insertions(+), 25 deletions(-) create mode 100644 lib/controllers/my-profile.js create mode 100644 views/activitypub-my-profile.njk diff --git a/assets/reader.css b/assets/reader.css index 33f6624..ba2540a 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -87,6 +87,20 @@ font-weight: 600; } +.ap-tab__count { + background: var(--color-offset-variant); + border-radius: var(--border-radius-large); + font-size: var(--font-size-xs); + font-weight: 600; + margin-left: var(--space-xs); + padding: 1px 6px; +} + +.ap-tab--active .ap-tab__count { + background: var(--color-primary); + color: var(--color-on-primary, var(--color-neutral99)); +} + /* ========================================================================== Timeline Layout ========================================================================== */ @@ -269,6 +283,16 @@ font-size: var(--font-size-xs); } +.ap-card__timestamp-link { + color: inherit; + text-decoration: none; +} + +.ap-card__timestamp-link:hover { + text-decoration: underline; + color: var(--color-primary); +} + /* ========================================================================== Post Title (Articles) ========================================================================== */ @@ -931,6 +955,30 @@ color: var(--color-red45); } +.ap-notification__actions { + display: flex; + gap: var(--space-s); + margin-top: var(--space-s); +} + +.ap-notification__reply-btn, +.ap-notification__thread-btn { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + color: var(--color-on-offset); + font-size: var(--font-size-s); + padding: var(--space-xs) var(--space-s); + text-decoration: none; + transition: all 0.2s ease; +} + +.ap-notification__reply-btn:hover, +.ap-notification__thread-btn:hover { + background: var(--color-offset-variant); + border-color: var(--color-outline-variant); + color: var(--color-on-background); +} + /* ========================================================================== Remote Profile ========================================================================== */ @@ -1071,6 +1119,127 @@ padding-bottom: var(--space-s); } +/* ========================================================================== + My Profile — Admin Profile Header + ========================================================================== */ + +.ap-my-profile { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + margin-bottom: var(--space-m); + overflow: hidden; +} + +.ap-my-profile__header { + height: 160px; + overflow: hidden; +} + +.ap-my-profile__header-img { + height: 100%; + object-fit: cover; + width: 100%; +} + +.ap-my-profile__info { + padding: var(--space-m); +} + +.ap-my-profile__avatar-wrap { + margin-bottom: var(--space-s); + margin-top: -40px; +} + +.ap-my-profile__avatar { + border: 3px solid var(--color-background); + border-radius: 50%; + height: 72px; + object-fit: cover; + width: 72px; +} + +.ap-my-profile__avatar--placeholder { + align-items: center; + background: var(--color-offset-variant); + color: var(--color-on-offset); + display: flex; + font-size: 1.8em; + font-weight: 600; + justify-content: center; +} + +.ap-my-profile__name { + font-size: var(--font-size-xl); + margin-bottom: 0; +} + +.ap-my-profile__handle { + color: var(--color-on-offset); + font-size: var(--font-size-s); + margin-bottom: var(--space-s); +} + +.ap-my-profile__bio { + line-height: var(--line-height-prose); + margin-bottom: var(--space-s); +} + +.ap-my-profile__bio a { + color: var(--color-primary); +} + +/* Override upstream .mention { display: grid } for bio content */ +.ap-my-profile__bio .h-card { display: inline; } +.ap-my-profile__bio .h-card a, +.ap-my-profile__bio a.u-url.mention { display: inline; white-space: nowrap; } +.ap-my-profile__bio .h-card a span, +.ap-my-profile__bio a.u-url.mention span { display: inline; } +.ap-my-profile__bio a.mention.hashtag { display: inline; white-space: nowrap; } +.ap-my-profile__bio a.mention.hashtag span { display: inline; } +.ap-my-profile__bio .invisible { display: none; } +.ap-my-profile__bio .ellipsis::after { content: "…"; } + +.ap-my-profile__stats { + display: flex; + gap: var(--space-m); + margin-bottom: var(--space-s); +} + +.ap-my-profile__stat { + color: var(--color-on-offset); + font-size: var(--font-size-s); + text-decoration: none; +} + +.ap-my-profile__stat:hover { + color: var(--color-on-background); +} + +.ap-my-profile__stat strong { + color: var(--color-on-background); + font-weight: 600; +} + +.ap-my-profile__edit { + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + color: var(--color-on-background); + display: inline-block; + font-size: var(--font-size-s); + padding: var(--space-xs) var(--space-m); + text-decoration: none; +} + +.ap-my-profile__edit:hover { + background: var(--color-offset); + border-color: var(--color-outline-variant); +} + +/* When no header image, don't offset avatar */ +.ap-my-profile__info:first-child .ap-my-profile__avatar-wrap { + margin-top: 0; +} + /* ========================================================================== Moderation ========================================================================== */ @@ -1184,3 +1353,71 @@ padding: var(--space-xs); } } + +/* ========================================================================== + Post Detail View — Thread Layout + ========================================================================== */ + +.ap-post-detail__back { + margin-bottom: var(--space-m); +} + +.ap-post-detail__back-link { + color: var(--color-primary); + font-size: var(--font-size-s); + text-decoration: none; +} + +.ap-post-detail__back-link:hover { + text-decoration: underline; +} + +.ap-post-detail__not-found { + background: var(--color-offset); + border-radius: var(--border-radius-small); + color: var(--color-on-offset); + padding: var(--space-l); + text-align: center; +} + +.ap-post-detail__section-title { + color: var(--color-on-offset); + font-size: var(--font-size-s); + font-weight: 600; + margin: var(--space-m) 0 var(--space-s); + padding-bottom: var(--space-xs); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Parent posts — indented with left border to show thread chain */ +.ap-post-detail__parents { + border-left: 3px solid var(--color-outline); + margin-bottom: var(--space-s); + padding-left: var(--space-m); +} + +.ap-post-detail__parent-item .ap-card { + opacity: 0.85; +} + +/* Main post — highlighted */ +.ap-post-detail__main { + margin-bottom: var(--space-m); +} + +.ap-post-detail__main .ap-card { + border-color: var(--color-primary); + box-shadow: 0 0 0 1px var(--color-primary); +} + +/* Replies — indented from the other side */ +.ap-post-detail__replies { + margin-left: var(--space-l); +} + +.ap-post-detail__reply-item { + border-left: 2px solid var(--color-outline); + padding-left: var(--space-m); + margin-bottom: var(--space-xs); +} diff --git a/index.js b/index.js index 01f609f..34d0b8e 100644 --- a/index.js +++ b/index.js @@ -59,6 +59,7 @@ import { } from "./lib/controllers/featured-tags.js"; import { resolveController } from "./lib/controllers/resolve.js"; import { publicProfileController } from "./lib/controllers/public-profile.js"; +import { myProfileController } from "./lib/controllers/my-profile.js"; import { noteObjectController } from "./lib/controllers/note-object.js"; import { refollowPauseController, @@ -127,6 +128,11 @@ export default class ActivityPubEndpoint { text: "activitypub.moderation.title", requiresDatabase: true, }, + { + href: `${this.options.mountPath}/admin/my-profile`, + text: "activitypub.myProfile.title", + requiresDatabase: true, + }, ]; } @@ -237,6 +243,7 @@ export default class ActivityPubEndpoint { router.post("/admin/tags/remove", featuredTagsRemoveController(mp, this)); router.get("/admin/profile", profileGetController(mp)); router.post("/admin/profile", profilePostController(mp, this)); + router.get("/admin/my-profile", myProfileController(this)); router.get("/admin/migrate", migrateGetController(mp, this.options)); router.post("/admin/migrate", migratePostController(mp, this.options)); router.post( @@ -927,6 +934,10 @@ export default class ActivityPubEndpoint { { read: 1 }, { background: true }, ); + this._collections.ap_notifications.createIndex( + { type: 1, published: -1 }, + { background: true }, + ); // TTL index for notification cleanup const notifRetention = this.options.notificationRetentionDays; diff --git a/lib/controllers/my-profile.js b/lib/controllers/my-profile.js new file mode 100644 index 0000000..1e9e384 --- /dev/null +++ b/lib/controllers/my-profile.js @@ -0,0 +1,251 @@ +/** + * 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: 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": { + const apActivities = collections.get("ap_activities"); + if (apActivities) { + const query = { + direction: "outbound", + type: "Create", + targetUrl: { $exists: true, $ne: null }, + }; + if (before) { + query.receivedAt = { $lt: before }; + } + + const activities = await apActivities + .find(query) + .sort({ receivedAt: -1 }) + .limit(PAGE_LIMIT) + .toArray(); + + items = activities.map((a) => ({ + uid: a.objectUrl, + url: a.objectUrl, + content: a.content + ? { text: a.content } + : { text: a.summary || "" }, + published: a.receivedAt, + inReplyTo: a.targetUrl, + type: "reply", + author: { + name: profile?.name || a.actorName || "", + url: profile?.url || a.actorUrl || "", + photo: profile?.icon || "", + }, + })); + + if (activities.length === PAGE_LIMIT) { + nextBefore = activities[activities.length - 1].receivedAt; + } + } + 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"), + profile: profile || {}, + handle, + domain, + fullHandle: `@${handle}@${domain}`, + followerCount, + followingCount, + postCount, + tab, + items, + before: nextBefore, + csrfToken, + interactionMap: {}, + mountPath, + }); + } catch (error) { + next(error); + } + }; +} diff --git a/lib/controllers/reader.js b/lib/controllers/reader.js index a8a567d..d51445c 100644 --- a/lib/controllers/reader.js +++ b/lib/controllers/reader.js @@ -6,6 +6,7 @@ import { getTimelineItems } from "../storage/timeline.js"; import { getNotifications, getUnreadNotificationCount, + getNotificationCountsByType, markAllNotificationsRead, clearAllNotifications, deleteNotification, @@ -39,7 +40,7 @@ export function readerController(mountPath) { }; // Query parameters - const tab = request.query.tab || "all"; + 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); @@ -177,6 +178,8 @@ export function readerController(mountPath) { } export function notificationsController(mountPath) { + const validTabs = ["all", "reply", "like", "boost", "follow"]; + return async (request, response, next) => { try { const { application } = request.app.locals; @@ -184,13 +187,24 @@ export function notificationsController(mountPath) { 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); - // Get notifications - const result = await getNotifications(collections, { before, limit }); + // Build query options with type filter + const options = { before, limit }; + if (tab !== "all") { + options.type = tab; + } - const unreadCount = await getUnreadNotificationCount(collections); + // Get filtered notifications + counts in parallel + const [result, unreadCount, tabCounts] = await Promise.all([ + getNotifications(collections, options), + getUnreadNotificationCount(collections), + getNotificationCountsByType(collections), + ]); // CSRF token for action forms const csrfToken = getToken(request.session); @@ -199,6 +213,8 @@ export function notificationsController(mountPath) { title: response.locals.__("activitypub.notifications.title"), items: result.items, before: result.before, + tab, + tabCounts, unreadCount, csrfToken, mountPath, diff --git a/lib/storage/notifications.js b/lib/storage/notifications.js index 2b471e2..ce6cd85 100644 --- a/lib/storage/notifications.js +++ b/lib/storage/notifications.js @@ -49,6 +49,7 @@ export async function addNotification(collections, notification) { * @param {string} [options.before] - Before cursor (published date) * @param {number} [options.limit=20] - Items per page * @param {boolean} [options.unreadOnly=false] - Show only unread notifications + * @param {string} [options.type] - Filter by notification type (like, boost, follow, reply, mention) * @returns {Promise} { items, before } */ export async function getNotifications(collections, options = {}) { @@ -61,6 +62,16 @@ export async function getNotifications(collections, options = {}) { const query = {}; + // Type filter + if (options.type) { + // "reply" tab shows both replies and mentions + if (options.type === "reply") { + query.type = { $in: ["reply", "mention"] }; + } else { + query.type = options.type; + } + } + // Unread filter if (options.unreadOnly) { query.read = false; @@ -98,6 +109,36 @@ export async function getNotifications(collections, options = {}) { }; } +/** + * Get notification counts grouped by type + * @param {object} collections - MongoDB collections + * @param {boolean} [unreadOnly=false] - Count only unread notifications + * @returns {Promise} Counts per type { all, reply, like, boost, follow } + */ +export async function getNotificationCountsByType(collections, unreadOnly = false) { + const { ap_notifications } = collections; + const matchStage = unreadOnly ? { $match: { read: false } } : { $match: {} }; + + const pipeline = [ + matchStage, + { $group: { _id: "$type", count: { $sum: 1 } } }, + ]; + + const results = await ap_notifications.aggregate(pipeline).toArray(); + + const counts = { all: 0, reply: 0, like: 0, boost: 0, follow: 0 }; + for (const { _id, count } of results) { + counts.all += count; + if (_id === "reply" || _id === "mention") { + counts.reply += count; + } else if (counts[_id] !== undefined) { + counts[_id] = count; + } + } + + return counts; +} + /** * Get count of unread notifications * @param {object} collections - MongoDB collections diff --git a/locales/en.json b/locales/en.json index 310cc19..6458948 100644 --- a/locales/en.json +++ b/locales/en.json @@ -157,7 +157,16 @@ "markAllRead": "Mark all read", "clearAll": "Clear all", "clearConfirm": "Delete all notifications? This cannot be undone.", - "dismiss": "Dismiss" + "dismiss": "Dismiss", + "viewThread": "View thread", + "tabs": { + "all": "All", + "replies": "Replies", + "likes": "Likes", + "boosts": "Boosts", + "follows": "Follows" + }, + "emptyTab": "No %s notifications yet." }, "reader": { "title": "Reader", @@ -213,6 +222,18 @@ "linkPreview": { "label": "Link preview" } + }, + "myProfile": { + "title": "My Profile", + "posts": "posts", + "editProfile": "Edit profile", + "empty": "Nothing here yet.", + "tabs": { + "posts": "Posts", + "replies": "Replies", + "likes": "Likes", + "boosts": "Boosts" + } } } } diff --git a/package.json b/package.json index ac0718e..d3faa79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "2.0.10", + "version": "2.0.11", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", diff --git a/views/activitypub-my-profile.njk b/views/activitypub-my-profile.njk new file mode 100644 index 0000000..4a27290 --- /dev/null +++ b/views/activitypub-my-profile.njk @@ -0,0 +1,85 @@ +{% extends "layouts/ap-reader.njk" %} + +{% from "prose/macro.njk" import prose with context %} + +{% block readercontent %} + {# Profile header #} +
+ {% if profile.image %} +
+ +
+ {% endif %} + +
+
+ {% if profile.icon %} + {{ profile.name }} + {% else %} + {{ profile.name[0] | upper if profile.name else "?" }} + {% endif %} +
+ +
+

{{ profile.name or handle }}

+
{{ fullHandle }}
+ {% if profile.summary %} +
{{ profile.summary | safe }}
+ {% endif %} +
+ + + + + {{ __("activitypub.myProfile.editProfile") }} + +
+
+ + {# Tab navigation #} + {% set profileBase = mountPath + "/admin/my-profile" %} + + + {# Activity items #} + {% if items.length > 0 %} +
+ {% for item in items %} + {% include "partials/ap-item-card.njk" %} + {% endfor %} +
+ + {# Pagination — preserve active tab #} + {% if before %} + + {% endif %} + {% else %} + {{ prose({ text: __("activitypub.myProfile.empty") }) }} + {% endif %} +{% endblock %} diff --git a/views/activitypub-notifications.njk b/views/activitypub-notifications.njk index d8064e3..d7beebf 100644 --- a/views/activitypub-notifications.njk +++ b/views/activitypub-notifications.njk @@ -4,31 +4,57 @@ {% from "prose/macro.njk" import prose with context %} {% block readercontent %} - {% if items.length > 0 %} -
- {% if unreadCount > 0 %} -
- - -
- {% endif %} -
- - -
-
+ {# Tab navigation #} + {% set notifBase = mountPath + "/admin/reader/notifications" %} + + {# Toolbar — mark read + clear all #} +
+ {% if unreadCount > 0 %} +
+ + +
+ {% endif %} +
+ + +
+
+ + {% if items.length > 0 %}
{% for item in items %} {% include "partials/ap-notification-card.njk" %} {% endfor %}
- {# Pagination #} + {# Pagination — preserve active tab #} {% if before %} diff --git a/views/partials/ap-item-card.njk b/views/partials/ap-item-card.njk index d3c41fb..b73a5cc 100644 --- a/views/partials/ap-item-card.njk +++ b/views/partials/ap-item-card.njk @@ -43,9 +43,11 @@ {% endif %} {% if item.published %} - + + + {% endif %} diff --git a/views/partials/ap-notification-card.njk b/views/partials/ap-notification-card.njk index 67aece0..8f729d2 100644 --- a/views/partials/ap-notification-card.njk +++ b/views/partials/ap-notification-card.njk @@ -53,6 +53,17 @@ {{ item.content.text | truncate(200) }} {% endif %} + + {% if item.type == "reply" or item.type == "mention" %} + + {% endif %} {# Timestamp #}