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
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<object>} { 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<object>} 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
|
||||
|
||||
+22
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "layouts/ap-reader.njk" %}
|
||||
|
||||
{% from "prose/macro.njk" import prose with context %}
|
||||
|
||||
{% block readercontent %}
|
||||
{# Profile header #}
|
||||
<div class="ap-my-profile">
|
||||
{% if profile.image %}
|
||||
<div class="ap-my-profile__header">
|
||||
<img src="{{ profile.image }}" alt="" class="ap-my-profile__header-img" loading="lazy" crossorigin="anonymous">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="ap-my-profile__info">
|
||||
<div class="ap-my-profile__avatar-wrap">
|
||||
{% if profile.icon %}
|
||||
<img src="{{ profile.icon }}" alt="{{ profile.name }}" class="ap-my-profile__avatar" loading="lazy" crossorigin="anonymous">
|
||||
{% else %}
|
||||
<span class="ap-my-profile__avatar ap-my-profile__avatar--placeholder">{{ profile.name[0] | upper if profile.name else "?" }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="ap-my-profile__meta">
|
||||
<h2 class="ap-my-profile__name">{{ profile.name or handle }}</h2>
|
||||
<div class="ap-my-profile__handle">{{ fullHandle }}</div>
|
||||
{% if profile.summary %}
|
||||
<div class="ap-my-profile__bio">{{ profile.summary | safe }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="ap-my-profile__stats">
|
||||
<a href="{{ mountPath }}/admin/followers" class="ap-my-profile__stat">
|
||||
<strong>{{ followerCount }}</strong> {{ __("activitypub.followers") }}
|
||||
</a>
|
||||
<a href="{{ mountPath }}/admin/following" class="ap-my-profile__stat">
|
||||
<strong>{{ followingCount }}</strong> {{ __("activitypub.following") }}
|
||||
</a>
|
||||
<span class="ap-my-profile__stat">
|
||||
<strong>{{ postCount }}</strong> {{ __("activitypub.myProfile.posts") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<a href="{{ mountPath }}/admin/profile" class="ap-my-profile__edit">
|
||||
{{ __("activitypub.myProfile.editProfile") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Tab navigation #}
|
||||
{% set profileBase = mountPath + "/admin/my-profile" %}
|
||||
<nav class="ap-tabs" role="tablist">
|
||||
<a href="{{ profileBase }}?tab=posts" class="ap-tab{% if tab == 'posts' %} ap-tab--active{% endif %}" role="tab">
|
||||
{{ __("activitypub.myProfile.tabs.posts") }}
|
||||
</a>
|
||||
<a href="{{ profileBase }}?tab=replies" class="ap-tab{% if tab == 'replies' %} ap-tab--active{% endif %}" role="tab">
|
||||
{{ __("activitypub.myProfile.tabs.replies") }}
|
||||
</a>
|
||||
<a href="{{ profileBase }}?tab=likes" class="ap-tab{% if tab == 'likes' %} ap-tab--active{% endif %}" role="tab">
|
||||
{{ __("activitypub.myProfile.tabs.likes") }}
|
||||
</a>
|
||||
<a href="{{ profileBase }}?tab=boosts" class="ap-tab{% if tab == 'boosts' %} ap-tab--active{% endif %}" role="tab">
|
||||
{{ __("activitypub.myProfile.tabs.boosts") }}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{# Activity items #}
|
||||
{% if items.length > 0 %}
|
||||
<div class="ap-timeline" data-mount-path="{{ mountPath }}">
|
||||
{% for item in items %}
|
||||
{% include "partials/ap-item-card.njk" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Pagination — preserve active tab #}
|
||||
{% if before %}
|
||||
<nav class="ap-pagination">
|
||||
<a href="?tab={{ tab }}&before={{ before }}" class="ap-pagination__next">
|
||||
{{ __("activitypub.reader.pagination.older") }}
|
||||
</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ prose({ text: __("activitypub.myProfile.empty") }) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -4,31 +4,57 @@
|
||||
{% from "prose/macro.njk" import prose with context %}
|
||||
|
||||
{% block readercontent %}
|
||||
{% if items.length > 0 %}
|
||||
{# Tab navigation #}
|
||||
{% set notifBase = mountPath + "/admin/reader/notifications" %}
|
||||
<nav class="ap-tabs">
|
||||
<a href="{{ notifBase }}?tab=reply" class="ap-tab{% if tab == 'reply' %} ap-tab--active{% endif %}">
|
||||
{{ __("activitypub.notifications.tabs.replies") }}
|
||||
{% if tabCounts.reply %}<span class="ap-tab__count">{{ tabCounts.reply }}</span>{% endif %}
|
||||
</a>
|
||||
<a href="{{ notifBase }}?tab=like" class="ap-tab{% if tab == 'like' %} ap-tab--active{% endif %}">
|
||||
{{ __("activitypub.notifications.tabs.likes") }}
|
||||
{% if tabCounts.like %}<span class="ap-tab__count">{{ tabCounts.like }}</span>{% endif %}
|
||||
</a>
|
||||
<a href="{{ notifBase }}?tab=boost" class="ap-tab{% if tab == 'boost' %} ap-tab--active{% endif %}">
|
||||
{{ __("activitypub.notifications.tabs.boosts") }}
|
||||
{% if tabCounts.boost %}<span class="ap-tab__count">{{ tabCounts.boost }}</span>{% endif %}
|
||||
</a>
|
||||
<a href="{{ notifBase }}?tab=follow" class="ap-tab{% if tab == 'follow' %} ap-tab--active{% endif %}">
|
||||
{{ __("activitypub.notifications.tabs.follows") }}
|
||||
{% if tabCounts.follow %}<span class="ap-tab__count">{{ tabCounts.follow }}</span>{% endif %}
|
||||
</a>
|
||||
<a href="{{ notifBase }}?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}">
|
||||
{{ __("activitypub.notifications.tabs.all") }}
|
||||
{% if tabCounts.all %}<span class="ap-tab__count">{{ tabCounts.all }}</span>{% endif %}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{# Toolbar — mark read + clear all #}
|
||||
<div class="ap-notifications__toolbar">
|
||||
{% if unreadCount > 0 %}
|
||||
<form method="post" action="{{ mountPath }}/admin/reader/notifications/mark-read">
|
||||
<form method="post" action="{{ notifBase }}/mark-read">
|
||||
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
||||
<button type="submit" class="ap-notifications__btn">{{ __("activitypub.notifications.markAllRead") }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ mountPath }}/admin/reader/notifications/clear"
|
||||
<form method="post" action="{{ notifBase }}/clear"
|
||||
onsubmit="return confirm('{{ __("activitypub.notifications.clearConfirm") }}')">
|
||||
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
||||
<button type="submit" class="ap-notifications__btn ap-notifications__btn--danger">{{ __("activitypub.notifications.clearAll") }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if items.length > 0 %}
|
||||
<div class="ap-timeline">
|
||||
{% for item in items %}
|
||||
{% include "partials/ap-notification-card.njk" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Pagination #}
|
||||
{# Pagination — preserve active tab #}
|
||||
{% if before %}
|
||||
<nav class="ap-pagination">
|
||||
<a href="?before={{ before }}" class="ap-pagination__next">
|
||||
<a href="?tab={{ tab }}&before={{ before }}" class="ap-pagination__next">
|
||||
{{ __("activitypub.reader.pagination.older") }}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -43,9 +43,11 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if item.published %}
|
||||
<a href="{{ mountPath }}/admin/reader/post?url={{ (item.uid or item.url) | urlencode }}" class="ap-card__timestamp-link" title="{{ __('activitypub.reader.post.title') }}">
|
||||
<time datetime="{{ item.published }}" class="ap-card__timestamp">
|
||||
{{ item.published | date("PPp") }}
|
||||
</time>
|
||||
</a>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
|
||||
@@ -53,6 +53,17 @@
|
||||
{{ item.content.text | truncate(200) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.type == "reply" or item.type == "mention" %}
|
||||
<div class="ap-notification__actions">
|
||||
<a href="{{ mountPath }}/admin/reader/compose?replyTo={{ item.uid | urlencode }}" class="ap-notification__reply-btn" title="{{ __('activitypub.reader.actions.reply') }}">
|
||||
↩ {{ __("activitypub.reader.actions.reply") }}
|
||||
</a>
|
||||
<a href="{{ mountPath }}/admin/reader/post?url={{ item.uid | urlencode }}" class="ap-notification__thread-btn" title="{{ __('activitypub.reader.post.title') }}">
|
||||
💬 {{ __("activitypub.notifications.viewThread") }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Timestamp #}
|
||||
|
||||
Reference in New Issue
Block a user