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:
Ricardo
2026-02-23 15:55:44 +01:00
parent 376a1bb938
commit 743cb6b85b
11 changed files with 726 additions and 25 deletions
+237
View File
@@ -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);
}
+11
View File
@@ -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;
+251
View File
@@ -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);
}
};
}
+20 -4
View File
@@ -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,
+41
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+85
View File
@@ -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 %}
+42 -16
View File
@@ -4,31 +4,57 @@
{% from "prose/macro.njk" import prose with context %}
{% block readercontent %}
{% if items.length > 0 %}
<div class="ap-notifications__toolbar">
{% if unreadCount > 0 %}
<form method="post" action="{{ mountPath }}/admin/reader/notifications/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"
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>
{# 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="{{ 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="{{ 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>
+5 -3
View File
@@ -43,9 +43,11 @@
{% endif %}
</div>
{% if item.published %}
<time datetime="{{ item.published }}" class="ap-card__timestamp">
{{ item.published | date("PPp") }}
</time>
<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>
+11
View File
@@ -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 #}