@rmdes/indiekit-endpoint-activitypub 2.0.10 → 2.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/reader.css CHANGED
@@ -87,6 +87,20 @@
87
87
  font-weight: 600;
88
88
  }
89
89
 
90
+ .ap-tab__count {
91
+ background: var(--color-offset-variant);
92
+ border-radius: var(--border-radius-large);
93
+ font-size: var(--font-size-xs);
94
+ font-weight: 600;
95
+ margin-left: var(--space-xs);
96
+ padding: 1px 6px;
97
+ }
98
+
99
+ .ap-tab--active .ap-tab__count {
100
+ background: var(--color-primary);
101
+ color: var(--color-on-primary, var(--color-neutral99));
102
+ }
103
+
90
104
  /* ==========================================================================
91
105
  Timeline Layout
92
106
  ========================================================================== */
@@ -269,6 +283,16 @@
269
283
  font-size: var(--font-size-xs);
270
284
  }
271
285
 
286
+ .ap-card__timestamp-link {
287
+ color: inherit;
288
+ text-decoration: none;
289
+ }
290
+
291
+ .ap-card__timestamp-link:hover {
292
+ text-decoration: underline;
293
+ color: var(--color-primary);
294
+ }
295
+
272
296
  /* ==========================================================================
273
297
  Post Title (Articles)
274
298
  ========================================================================== */
@@ -931,6 +955,30 @@
931
955
  color: var(--color-red45);
932
956
  }
933
957
 
958
+ .ap-notification__actions {
959
+ display: flex;
960
+ gap: var(--space-s);
961
+ margin-top: var(--space-s);
962
+ }
963
+
964
+ .ap-notification__reply-btn,
965
+ .ap-notification__thread-btn {
966
+ border: var(--border-width-thin) solid var(--color-outline);
967
+ border-radius: var(--border-radius-small);
968
+ color: var(--color-on-offset);
969
+ font-size: var(--font-size-s);
970
+ padding: var(--space-xs) var(--space-s);
971
+ text-decoration: none;
972
+ transition: all 0.2s ease;
973
+ }
974
+
975
+ .ap-notification__reply-btn:hover,
976
+ .ap-notification__thread-btn:hover {
977
+ background: var(--color-offset-variant);
978
+ border-color: var(--color-outline-variant);
979
+ color: var(--color-on-background);
980
+ }
981
+
934
982
  /* ==========================================================================
935
983
  Remote Profile
936
984
  ========================================================================== */
@@ -1071,6 +1119,127 @@
1071
1119
  padding-bottom: var(--space-s);
1072
1120
  }
1073
1121
 
1122
+ /* ==========================================================================
1123
+ My Profile — Admin Profile Header
1124
+ ========================================================================== */
1125
+
1126
+ .ap-my-profile {
1127
+ border: var(--border-width-thin) solid var(--color-outline);
1128
+ border-radius: var(--border-radius-small);
1129
+ margin-bottom: var(--space-m);
1130
+ overflow: hidden;
1131
+ }
1132
+
1133
+ .ap-my-profile__header {
1134
+ height: 160px;
1135
+ overflow: hidden;
1136
+ }
1137
+
1138
+ .ap-my-profile__header-img {
1139
+ height: 100%;
1140
+ object-fit: cover;
1141
+ width: 100%;
1142
+ }
1143
+
1144
+ .ap-my-profile__info {
1145
+ padding: var(--space-m);
1146
+ }
1147
+
1148
+ .ap-my-profile__avatar-wrap {
1149
+ margin-bottom: var(--space-s);
1150
+ margin-top: -40px;
1151
+ }
1152
+
1153
+ .ap-my-profile__avatar {
1154
+ border: 3px solid var(--color-background);
1155
+ border-radius: 50%;
1156
+ height: 72px;
1157
+ object-fit: cover;
1158
+ width: 72px;
1159
+ }
1160
+
1161
+ .ap-my-profile__avatar--placeholder {
1162
+ align-items: center;
1163
+ background: var(--color-offset-variant);
1164
+ color: var(--color-on-offset);
1165
+ display: flex;
1166
+ font-size: 1.8em;
1167
+ font-weight: 600;
1168
+ justify-content: center;
1169
+ }
1170
+
1171
+ .ap-my-profile__name {
1172
+ font-size: var(--font-size-xl);
1173
+ margin-bottom: 0;
1174
+ }
1175
+
1176
+ .ap-my-profile__handle {
1177
+ color: var(--color-on-offset);
1178
+ font-size: var(--font-size-s);
1179
+ margin-bottom: var(--space-s);
1180
+ }
1181
+
1182
+ .ap-my-profile__bio {
1183
+ line-height: var(--line-height-prose);
1184
+ margin-bottom: var(--space-s);
1185
+ }
1186
+
1187
+ .ap-my-profile__bio a {
1188
+ color: var(--color-primary);
1189
+ }
1190
+
1191
+ /* Override upstream .mention { display: grid } for bio content */
1192
+ .ap-my-profile__bio .h-card { display: inline; }
1193
+ .ap-my-profile__bio .h-card a,
1194
+ .ap-my-profile__bio a.u-url.mention { display: inline; white-space: nowrap; }
1195
+ .ap-my-profile__bio .h-card a span,
1196
+ .ap-my-profile__bio a.u-url.mention span { display: inline; }
1197
+ .ap-my-profile__bio a.mention.hashtag { display: inline; white-space: nowrap; }
1198
+ .ap-my-profile__bio a.mention.hashtag span { display: inline; }
1199
+ .ap-my-profile__bio .invisible { display: none; }
1200
+ .ap-my-profile__bio .ellipsis::after { content: "…"; }
1201
+
1202
+ .ap-my-profile__stats {
1203
+ display: flex;
1204
+ gap: var(--space-m);
1205
+ margin-bottom: var(--space-s);
1206
+ }
1207
+
1208
+ .ap-my-profile__stat {
1209
+ color: var(--color-on-offset);
1210
+ font-size: var(--font-size-s);
1211
+ text-decoration: none;
1212
+ }
1213
+
1214
+ .ap-my-profile__stat:hover {
1215
+ color: var(--color-on-background);
1216
+ }
1217
+
1218
+ .ap-my-profile__stat strong {
1219
+ color: var(--color-on-background);
1220
+ font-weight: 600;
1221
+ }
1222
+
1223
+ .ap-my-profile__edit {
1224
+ border: var(--border-width-thin) solid var(--color-outline);
1225
+ border-radius: var(--border-radius-small);
1226
+ color: var(--color-on-background);
1227
+ display: inline-block;
1228
+ font-size: var(--font-size-s);
1229
+ padding: var(--space-xs) var(--space-m);
1230
+ text-decoration: none;
1231
+ }
1232
+
1233
+ .ap-my-profile__edit:hover {
1234
+ background: var(--color-offset);
1235
+ border-color: var(--color-outline-variant);
1236
+ }
1237
+
1238
+ /* When no header image, don't offset avatar */
1239
+ .ap-my-profile__info:first-child .ap-my-profile__avatar-wrap {
1240
+ margin-top: 0;
1241
+ }
1242
+
1074
1243
  /* ==========================================================================
1075
1244
  Moderation
1076
1245
  ========================================================================== */
@@ -1184,3 +1353,71 @@
1184
1353
  padding: var(--space-xs);
1185
1354
  }
1186
1355
  }
1356
+
1357
+ /* ==========================================================================
1358
+ Post Detail View — Thread Layout
1359
+ ========================================================================== */
1360
+
1361
+ .ap-post-detail__back {
1362
+ margin-bottom: var(--space-m);
1363
+ }
1364
+
1365
+ .ap-post-detail__back-link {
1366
+ color: var(--color-primary);
1367
+ font-size: var(--font-size-s);
1368
+ text-decoration: none;
1369
+ }
1370
+
1371
+ .ap-post-detail__back-link:hover {
1372
+ text-decoration: underline;
1373
+ }
1374
+
1375
+ .ap-post-detail__not-found {
1376
+ background: var(--color-offset);
1377
+ border-radius: var(--border-radius-small);
1378
+ color: var(--color-on-offset);
1379
+ padding: var(--space-l);
1380
+ text-align: center;
1381
+ }
1382
+
1383
+ .ap-post-detail__section-title {
1384
+ color: var(--color-on-offset);
1385
+ font-size: var(--font-size-s);
1386
+ font-weight: 600;
1387
+ margin: var(--space-m) 0 var(--space-s);
1388
+ padding-bottom: var(--space-xs);
1389
+ text-transform: uppercase;
1390
+ letter-spacing: 0.05em;
1391
+ }
1392
+
1393
+ /* Parent posts — indented with left border to show thread chain */
1394
+ .ap-post-detail__parents {
1395
+ border-left: 3px solid var(--color-outline);
1396
+ margin-bottom: var(--space-s);
1397
+ padding-left: var(--space-m);
1398
+ }
1399
+
1400
+ .ap-post-detail__parent-item .ap-card {
1401
+ opacity: 0.85;
1402
+ }
1403
+
1404
+ /* Main post — highlighted */
1405
+ .ap-post-detail__main {
1406
+ margin-bottom: var(--space-m);
1407
+ }
1408
+
1409
+ .ap-post-detail__main .ap-card {
1410
+ border-color: var(--color-primary);
1411
+ box-shadow: 0 0 0 1px var(--color-primary);
1412
+ }
1413
+
1414
+ /* Replies — indented from the other side */
1415
+ .ap-post-detail__replies {
1416
+ margin-left: var(--space-l);
1417
+ }
1418
+
1419
+ .ap-post-detail__reply-item {
1420
+ border-left: 2px solid var(--color-outline);
1421
+ padding-left: var(--space-m);
1422
+ margin-bottom: var(--space-xs);
1423
+ }
package/index.js CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  } from "./lib/controllers/featured-tags.js";
60
60
  import { resolveController } from "./lib/controllers/resolve.js";
61
61
  import { publicProfileController } from "./lib/controllers/public-profile.js";
62
+ import { myProfileController } from "./lib/controllers/my-profile.js";
62
63
  import { noteObjectController } from "./lib/controllers/note-object.js";
63
64
  import {
64
65
  refollowPauseController,
@@ -127,6 +128,11 @@ export default class ActivityPubEndpoint {
127
128
  text: "activitypub.moderation.title",
128
129
  requiresDatabase: true,
129
130
  },
131
+ {
132
+ href: `${this.options.mountPath}/admin/my-profile`,
133
+ text: "activitypub.myProfile.title",
134
+ requiresDatabase: true,
135
+ },
130
136
  ];
131
137
  }
132
138
 
@@ -237,6 +243,7 @@ export default class ActivityPubEndpoint {
237
243
  router.post("/admin/tags/remove", featuredTagsRemoveController(mp, this));
238
244
  router.get("/admin/profile", profileGetController(mp));
239
245
  router.post("/admin/profile", profilePostController(mp, this));
246
+ router.get("/admin/my-profile", myProfileController(this));
240
247
  router.get("/admin/migrate", migrateGetController(mp, this.options));
241
248
  router.post("/admin/migrate", migratePostController(mp, this.options));
242
249
  router.post(
@@ -927,6 +934,10 @@ export default class ActivityPubEndpoint {
927
934
  { read: 1 },
928
935
  { background: true },
929
936
  );
937
+ this._collections.ap_notifications.createIndex(
938
+ { type: 1, published: -1 },
939
+ { background: true },
940
+ );
930
941
 
931
942
  // TTL index for notification cleanup
932
943
  const notifRetention = this.options.notificationRetentionDays;
@@ -0,0 +1,251 @@
1
+ /**
2
+ * My Profile controller — admin view of own profile and outbound activity.
3
+ * Shows profile header + tabbed activity (posts, replies, likes, boosts).
4
+ */
5
+
6
+ import { getToken } from "../csrf.js";
7
+
8
+ const VALID_TABS = ["posts", "replies", "likes", "boosts"];
9
+ const PAGE_LIMIT = 20;
10
+
11
+ /**
12
+ * Normalize a JF2 post from the Indiekit `posts` collection into the
13
+ * shape expected by the ap-item-card.njk partial.
14
+ */
15
+ function postToCardItem(post, profile) {
16
+ const props = post.properties || {};
17
+ const contentProp = props.content;
18
+ const content =
19
+ typeof contentProp === "string" ? { text: contentProp } : contentProp || {};
20
+
21
+ // Normalize photo to array of { url } objects
22
+ let photo = [];
23
+ if (props.photo) {
24
+ const photos = Array.isArray(props.photo) ? props.photo : [props.photo];
25
+ photo = photos.map((p) => (typeof p === "string" ? { url: p } : p));
26
+ }
27
+
28
+ return {
29
+ uid: props.url,
30
+ url: props.url,
31
+ name: props.name || "",
32
+ content,
33
+ published: props.published,
34
+ type: props["post-type"] || "note",
35
+ author: {
36
+ name: profile?.name || "",
37
+ url: profile?.url || "",
38
+ photo: profile?.icon || "",
39
+ },
40
+ photo,
41
+ category: props.category || [],
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Enrich interaction records (likes/boosts) with timeline data.
47
+ * Returns card items sorted by interaction date.
48
+ */
49
+ async function enrichInteractions(interactions, apTimeline) {
50
+ if (!interactions.length) return [];
51
+
52
+ const urls = interactions.map((i) => i.objectUrl);
53
+ const timelinePosts = apTimeline
54
+ ? await apTimeline.find({ uid: { $in: urls } }).toArray()
55
+ : [];
56
+ const postMap = new Map(timelinePosts.map((p) => [p.uid, p]));
57
+
58
+ return interactions.map((interaction) => {
59
+ const post = postMap.get(interaction.objectUrl);
60
+ if (post) {
61
+ return {
62
+ ...post,
63
+ published:
64
+ post.published instanceof Date
65
+ ? post.published.toISOString()
66
+ : post.published,
67
+ _interactionDate: interaction.createdAt,
68
+ };
69
+ }
70
+ // Fallback: minimal card with just the URL
71
+ return {
72
+ uid: interaction.objectUrl,
73
+ url: interaction.objectUrl,
74
+ content: { text: interaction.objectUrl },
75
+ published: interaction.createdAt,
76
+ type: "note",
77
+ author: { name: "", url: "", photo: "" },
78
+ };
79
+ });
80
+ }
81
+
82
+ export function myProfileController(plugin) {
83
+ const mountPath = plugin.options.mountPath;
84
+
85
+ return async (request, response, next) => {
86
+ try {
87
+ const { application } = request.app.locals;
88
+ const collections = application.collections;
89
+
90
+ const tab = VALID_TABS.includes(request.query.tab)
91
+ ? request.query.tab
92
+ : "posts";
93
+ const before = request.query.before;
94
+
95
+ // Profile header data (parallel)
96
+ const apProfile = collections.get("ap_profile");
97
+ const apFollowers = collections.get("ap_followers");
98
+ const apFollowing = collections.get("ap_following");
99
+ const postsCollection = collections.get("posts");
100
+
101
+ const [profile, followerCount, followingCount, postCount] =
102
+ await Promise.all([
103
+ apProfile ? apProfile.findOne({}) : null,
104
+ apFollowers ? apFollowers.countDocuments() : 0,
105
+ apFollowing ? apFollowing.countDocuments() : 0,
106
+ postsCollection ? postsCollection.countDocuments() : 0,
107
+ ]);
108
+
109
+ const domain = new URL(plugin._publicationUrl).hostname;
110
+ const handle = plugin.options.actor.handle;
111
+
112
+ // Tab data
113
+ let items = [];
114
+ let nextBefore = null;
115
+
116
+ switch (tab) {
117
+ case "posts": {
118
+ const query = {};
119
+ if (before) {
120
+ query["properties.published"] = { $lt: before };
121
+ }
122
+
123
+ const posts = postsCollection
124
+ ? await postsCollection
125
+ .find(query)
126
+ .sort({ "properties.published": -1 })
127
+ .limit(PAGE_LIMIT)
128
+ .toArray()
129
+ : [];
130
+
131
+ items = posts.map((p) => postToCardItem(p, profile));
132
+
133
+ if (posts.length === PAGE_LIMIT) {
134
+ nextBefore = items[items.length - 1].published;
135
+ }
136
+ break;
137
+ }
138
+
139
+ case "replies": {
140
+ const apActivities = collections.get("ap_activities");
141
+ if (apActivities) {
142
+ const query = {
143
+ direction: "outbound",
144
+ type: "Create",
145
+ targetUrl: { $exists: true, $ne: null },
146
+ };
147
+ if (before) {
148
+ query.receivedAt = { $lt: before };
149
+ }
150
+
151
+ const activities = await apActivities
152
+ .find(query)
153
+ .sort({ receivedAt: -1 })
154
+ .limit(PAGE_LIMIT)
155
+ .toArray();
156
+
157
+ items = activities.map((a) => ({
158
+ uid: a.objectUrl,
159
+ url: a.objectUrl,
160
+ content: a.content
161
+ ? { text: a.content }
162
+ : { text: a.summary || "" },
163
+ published: a.receivedAt,
164
+ inReplyTo: a.targetUrl,
165
+ type: "reply",
166
+ author: {
167
+ name: profile?.name || a.actorName || "",
168
+ url: profile?.url || a.actorUrl || "",
169
+ photo: profile?.icon || "",
170
+ },
171
+ }));
172
+
173
+ if (activities.length === PAGE_LIMIT) {
174
+ nextBefore = activities[activities.length - 1].receivedAt;
175
+ }
176
+ }
177
+ break;
178
+ }
179
+
180
+ case "likes": {
181
+ const apInteractions = collections.get("ap_interactions");
182
+ const apTimeline = collections.get("ap_timeline");
183
+ if (apInteractions) {
184
+ const query = { type: "like" };
185
+ if (before) {
186
+ query.createdAt = { $lt: before };
187
+ }
188
+
189
+ const likes = await apInteractions
190
+ .find(query)
191
+ .sort({ createdAt: -1 })
192
+ .limit(PAGE_LIMIT)
193
+ .toArray();
194
+
195
+ items = await enrichInteractions(likes, apTimeline);
196
+
197
+ if (likes.length === PAGE_LIMIT) {
198
+ nextBefore = likes[likes.length - 1].createdAt;
199
+ }
200
+ }
201
+ break;
202
+ }
203
+
204
+ case "boosts": {
205
+ const apInteractions = collections.get("ap_interactions");
206
+ const apTimeline = collections.get("ap_timeline");
207
+ if (apInteractions) {
208
+ const query = { type: "boost" };
209
+ if (before) {
210
+ query.createdAt = { $lt: before };
211
+ }
212
+
213
+ const boosts = await apInteractions
214
+ .find(query)
215
+ .sort({ createdAt: -1 })
216
+ .limit(PAGE_LIMIT)
217
+ .toArray();
218
+
219
+ items = await enrichInteractions(boosts, apTimeline);
220
+
221
+ if (boosts.length === PAGE_LIMIT) {
222
+ nextBefore = boosts[boosts.length - 1].createdAt;
223
+ }
224
+ }
225
+ break;
226
+ }
227
+ }
228
+
229
+ const csrfToken = getToken(request.session);
230
+
231
+ response.render("activitypub-my-profile", {
232
+ title: response.locals.__("activitypub.myProfile.title"),
233
+ profile: profile || {},
234
+ handle,
235
+ domain,
236
+ fullHandle: `@${handle}@${domain}`,
237
+ followerCount,
238
+ followingCount,
239
+ postCount,
240
+ tab,
241
+ items,
242
+ before: nextBefore,
243
+ csrfToken,
244
+ interactionMap: {},
245
+ mountPath,
246
+ });
247
+ } catch (error) {
248
+ next(error);
249
+ }
250
+ };
251
+ }
@@ -6,6 +6,7 @@ import { getTimelineItems } from "../storage/timeline.js";
6
6
  import {
7
7
  getNotifications,
8
8
  getUnreadNotificationCount,
9
+ getNotificationCountsByType,
9
10
  markAllNotificationsRead,
10
11
  clearAllNotifications,
11
12
  deleteNotification,
@@ -39,7 +40,7 @@ export function readerController(mountPath) {
39
40
  };
40
41
 
41
42
  // Query parameters
42
- const tab = request.query.tab || "all";
43
+ const tab = request.query.tab || "notes";
43
44
  const before = request.query.before;
44
45
  const after = request.query.after;
45
46
  const limit = Number.parseInt(request.query.limit || "20", 10);
@@ -177,6 +178,8 @@ export function readerController(mountPath) {
177
178
  }
178
179
 
179
180
  export function notificationsController(mountPath) {
181
+ const validTabs = ["all", "reply", "like", "boost", "follow"];
182
+
180
183
  return async (request, response, next) => {
181
184
  try {
182
185
  const { application } = request.app.locals;
@@ -184,13 +187,24 @@ export function notificationsController(mountPath) {
184
187
  ap_notifications: application?.collections?.get("ap_notifications"),
185
188
  };
186
189
 
190
+ const tab = validTabs.includes(request.query.tab)
191
+ ? request.query.tab
192
+ : "reply";
187
193
  const before = request.query.before;
188
194
  const limit = Number.parseInt(request.query.limit || "20", 10);
189
195
 
190
- // Get notifications
191
- const result = await getNotifications(collections, { before, limit });
196
+ // Build query options with type filter
197
+ const options = { before, limit };
198
+ if (tab !== "all") {
199
+ options.type = tab;
200
+ }
192
201
 
193
- const unreadCount = await getUnreadNotificationCount(collections);
202
+ // Get filtered notifications + counts in parallel
203
+ const [result, unreadCount, tabCounts] = await Promise.all([
204
+ getNotifications(collections, options),
205
+ getUnreadNotificationCount(collections),
206
+ getNotificationCountsByType(collections),
207
+ ]);
194
208
 
195
209
  // CSRF token for action forms
196
210
  const csrfToken = getToken(request.session);
@@ -199,6 +213,8 @@ export function notificationsController(mountPath) {
199
213
  title: response.locals.__("activitypub.notifications.title"),
200
214
  items: result.items,
201
215
  before: result.before,
216
+ tab,
217
+ tabCounts,
202
218
  unreadCount,
203
219
  csrfToken,
204
220
  mountPath,
@@ -49,6 +49,7 @@ export async function addNotification(collections, notification) {
49
49
  * @param {string} [options.before] - Before cursor (published date)
50
50
  * @param {number} [options.limit=20] - Items per page
51
51
  * @param {boolean} [options.unreadOnly=false] - Show only unread notifications
52
+ * @param {string} [options.type] - Filter by notification type (like, boost, follow, reply, mention)
52
53
  * @returns {Promise<object>} { items, before }
53
54
  */
54
55
  export async function getNotifications(collections, options = {}) {
@@ -61,6 +62,16 @@ export async function getNotifications(collections, options = {}) {
61
62
 
62
63
  const query = {};
63
64
 
65
+ // Type filter
66
+ if (options.type) {
67
+ // "reply" tab shows both replies and mentions
68
+ if (options.type === "reply") {
69
+ query.type = { $in: ["reply", "mention"] };
70
+ } else {
71
+ query.type = options.type;
72
+ }
73
+ }
74
+
64
75
  // Unread filter
65
76
  if (options.unreadOnly) {
66
77
  query.read = false;
@@ -98,6 +109,36 @@ export async function getNotifications(collections, options = {}) {
98
109
  };
99
110
  }
100
111
 
112
+ /**
113
+ * Get notification counts grouped by type
114
+ * @param {object} collections - MongoDB collections
115
+ * @param {boolean} [unreadOnly=false] - Count only unread notifications
116
+ * @returns {Promise<object>} Counts per type { all, reply, like, boost, follow }
117
+ */
118
+ export async function getNotificationCountsByType(collections, unreadOnly = false) {
119
+ const { ap_notifications } = collections;
120
+ const matchStage = unreadOnly ? { $match: { read: false } } : { $match: {} };
121
+
122
+ const pipeline = [
123
+ matchStage,
124
+ { $group: { _id: "$type", count: { $sum: 1 } } },
125
+ ];
126
+
127
+ const results = await ap_notifications.aggregate(pipeline).toArray();
128
+
129
+ const counts = { all: 0, reply: 0, like: 0, boost: 0, follow: 0 };
130
+ for (const { _id, count } of results) {
131
+ counts.all += count;
132
+ if (_id === "reply" || _id === "mention") {
133
+ counts.reply += count;
134
+ } else if (counts[_id] !== undefined) {
135
+ counts[_id] = count;
136
+ }
137
+ }
138
+
139
+ return counts;
140
+ }
141
+
101
142
  /**
102
143
  * Get count of unread notifications
103
144
  * @param {object} collections - MongoDB collections
package/locales/en.json CHANGED
@@ -157,7 +157,16 @@
157
157
  "markAllRead": "Mark all read",
158
158
  "clearAll": "Clear all",
159
159
  "clearConfirm": "Delete all notifications? This cannot be undone.",
160
- "dismiss": "Dismiss"
160
+ "dismiss": "Dismiss",
161
+ "viewThread": "View thread",
162
+ "tabs": {
163
+ "all": "All",
164
+ "replies": "Replies",
165
+ "likes": "Likes",
166
+ "boosts": "Boosts",
167
+ "follows": "Follows"
168
+ },
169
+ "emptyTab": "No %s notifications yet."
161
170
  },
162
171
  "reader": {
163
172
  "title": "Reader",
@@ -213,6 +222,18 @@
213
222
  "linkPreview": {
214
223
  "label": "Link preview"
215
224
  }
225
+ },
226
+ "myProfile": {
227
+ "title": "My Profile",
228
+ "posts": "posts",
229
+ "editProfile": "Edit profile",
230
+ "empty": "Nothing here yet.",
231
+ "tabs": {
232
+ "posts": "Posts",
233
+ "replies": "Replies",
234
+ "likes": "Likes",
235
+ "boosts": "Boosts"
236
+ }
216
237
  }
217
238
  }
218
239
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.0.10",
3
+ "version": "2.0.11",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -0,0 +1,85 @@
1
+ {% extends "layouts/ap-reader.njk" %}
2
+
3
+ {% from "prose/macro.njk" import prose with context %}
4
+
5
+ {% block readercontent %}
6
+ {# Profile header #}
7
+ <div class="ap-my-profile">
8
+ {% if profile.image %}
9
+ <div class="ap-my-profile__header">
10
+ <img src="{{ profile.image }}" alt="" class="ap-my-profile__header-img" loading="lazy" crossorigin="anonymous">
11
+ </div>
12
+ {% endif %}
13
+
14
+ <div class="ap-my-profile__info">
15
+ <div class="ap-my-profile__avatar-wrap">
16
+ {% if profile.icon %}
17
+ <img src="{{ profile.icon }}" alt="{{ profile.name }}" class="ap-my-profile__avatar" loading="lazy" crossorigin="anonymous">
18
+ {% else %}
19
+ <span class="ap-my-profile__avatar ap-my-profile__avatar--placeholder">{{ profile.name[0] | upper if profile.name else "?" }}</span>
20
+ {% endif %}
21
+ </div>
22
+
23
+ <div class="ap-my-profile__meta">
24
+ <h2 class="ap-my-profile__name">{{ profile.name or handle }}</h2>
25
+ <div class="ap-my-profile__handle">{{ fullHandle }}</div>
26
+ {% if profile.summary %}
27
+ <div class="ap-my-profile__bio">{{ profile.summary | safe }}</div>
28
+ {% endif %}
29
+ </div>
30
+
31
+ <div class="ap-my-profile__stats">
32
+ <a href="{{ mountPath }}/admin/followers" class="ap-my-profile__stat">
33
+ <strong>{{ followerCount }}</strong> {{ __("activitypub.followers") }}
34
+ </a>
35
+ <a href="{{ mountPath }}/admin/following" class="ap-my-profile__stat">
36
+ <strong>{{ followingCount }}</strong> {{ __("activitypub.following") }}
37
+ </a>
38
+ <span class="ap-my-profile__stat">
39
+ <strong>{{ postCount }}</strong> {{ __("activitypub.myProfile.posts") }}
40
+ </span>
41
+ </div>
42
+
43
+ <a href="{{ mountPath }}/admin/profile" class="ap-my-profile__edit">
44
+ {{ __("activitypub.myProfile.editProfile") }}
45
+ </a>
46
+ </div>
47
+ </div>
48
+
49
+ {# Tab navigation #}
50
+ {% set profileBase = mountPath + "/admin/my-profile" %}
51
+ <nav class="ap-tabs" role="tablist">
52
+ <a href="{{ profileBase }}?tab=posts" class="ap-tab{% if tab == 'posts' %} ap-tab--active{% endif %}" role="tab">
53
+ {{ __("activitypub.myProfile.tabs.posts") }}
54
+ </a>
55
+ <a href="{{ profileBase }}?tab=replies" class="ap-tab{% if tab == 'replies' %} ap-tab--active{% endif %}" role="tab">
56
+ {{ __("activitypub.myProfile.tabs.replies") }}
57
+ </a>
58
+ <a href="{{ profileBase }}?tab=likes" class="ap-tab{% if tab == 'likes' %} ap-tab--active{% endif %}" role="tab">
59
+ {{ __("activitypub.myProfile.tabs.likes") }}
60
+ </a>
61
+ <a href="{{ profileBase }}?tab=boosts" class="ap-tab{% if tab == 'boosts' %} ap-tab--active{% endif %}" role="tab">
62
+ {{ __("activitypub.myProfile.tabs.boosts") }}
63
+ </a>
64
+ </nav>
65
+
66
+ {# Activity items #}
67
+ {% if items.length > 0 %}
68
+ <div class="ap-timeline" data-mount-path="{{ mountPath }}">
69
+ {% for item in items %}
70
+ {% include "partials/ap-item-card.njk" %}
71
+ {% endfor %}
72
+ </div>
73
+
74
+ {# Pagination — preserve active tab #}
75
+ {% if before %}
76
+ <nav class="ap-pagination">
77
+ <a href="?tab={{ tab }}&before={{ before }}" class="ap-pagination__next">
78
+ {{ __("activitypub.reader.pagination.older") }}
79
+ </a>
80
+ </nav>
81
+ {% endif %}
82
+ {% else %}
83
+ {{ prose({ text: __("activitypub.myProfile.empty") }) }}
84
+ {% endif %}
85
+ {% endblock %}
@@ -4,31 +4,57 @@
4
4
  {% from "prose/macro.njk" import prose with context %}
5
5
 
6
6
  {% block readercontent %}
7
- {% if items.length > 0 %}
8
- <div class="ap-notifications__toolbar">
9
- {% if unreadCount > 0 %}
10
- <form method="post" action="{{ mountPath }}/admin/reader/notifications/mark-read">
11
- <input type="hidden" name="_csrf" value="{{ csrfToken }}">
12
- <button type="submit" class="ap-notifications__btn">{{ __("activitypub.notifications.markAllRead") }}</button>
13
- </form>
14
- {% endif %}
15
- <form method="post" action="{{ mountPath }}/admin/reader/notifications/clear"
16
- onsubmit="return confirm('{{ __("activitypub.notifications.clearConfirm") }}')">
7
+ {# Tab navigation #}
8
+ {% set notifBase = mountPath + "/admin/reader/notifications" %}
9
+ <nav class="ap-tabs">
10
+ <a href="{{ notifBase }}?tab=reply" class="ap-tab{% if tab == 'reply' %} ap-tab--active{% endif %}">
11
+ {{ __("activitypub.notifications.tabs.replies") }}
12
+ {% if tabCounts.reply %}<span class="ap-tab__count">{{ tabCounts.reply }}</span>{% endif %}
13
+ </a>
14
+ <a href="{{ notifBase }}?tab=like" class="ap-tab{% if tab == 'like' %} ap-tab--active{% endif %}">
15
+ {{ __("activitypub.notifications.tabs.likes") }}
16
+ {% if tabCounts.like %}<span class="ap-tab__count">{{ tabCounts.like }}</span>{% endif %}
17
+ </a>
18
+ <a href="{{ notifBase }}?tab=boost" class="ap-tab{% if tab == 'boost' %} ap-tab--active{% endif %}">
19
+ {{ __("activitypub.notifications.tabs.boosts") }}
20
+ {% if tabCounts.boost %}<span class="ap-tab__count">{{ tabCounts.boost }}</span>{% endif %}
21
+ </a>
22
+ <a href="{{ notifBase }}?tab=follow" class="ap-tab{% if tab == 'follow' %} ap-tab--active{% endif %}">
23
+ {{ __("activitypub.notifications.tabs.follows") }}
24
+ {% if tabCounts.follow %}<span class="ap-tab__count">{{ tabCounts.follow }}</span>{% endif %}
25
+ </a>
26
+ <a href="{{ notifBase }}?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}">
27
+ {{ __("activitypub.notifications.tabs.all") }}
28
+ {% if tabCounts.all %}<span class="ap-tab__count">{{ tabCounts.all }}</span>{% endif %}
29
+ </a>
30
+ </nav>
31
+
32
+ {# Toolbar — mark read + clear all #}
33
+ <div class="ap-notifications__toolbar">
34
+ {% if unreadCount > 0 %}
35
+ <form method="post" action="{{ notifBase }}/mark-read">
17
36
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
18
- <button type="submit" class="ap-notifications__btn ap-notifications__btn--danger">{{ __("activitypub.notifications.clearAll") }}</button>
37
+ <button type="submit" class="ap-notifications__btn">{{ __("activitypub.notifications.markAllRead") }}</button>
19
38
  </form>
20
- </div>
39
+ {% endif %}
40
+ <form method="post" action="{{ notifBase }}/clear"
41
+ onsubmit="return confirm('{{ __("activitypub.notifications.clearConfirm") }}')">
42
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
43
+ <button type="submit" class="ap-notifications__btn ap-notifications__btn--danger">{{ __("activitypub.notifications.clearAll") }}</button>
44
+ </form>
45
+ </div>
21
46
 
47
+ {% if items.length > 0 %}
22
48
  <div class="ap-timeline">
23
49
  {% for item in items %}
24
50
  {% include "partials/ap-notification-card.njk" %}
25
51
  {% endfor %}
26
52
  </div>
27
53
 
28
- {# Pagination #}
54
+ {# Pagination — preserve active tab #}
29
55
  {% if before %}
30
56
  <nav class="ap-pagination">
31
- <a href="?before={{ before }}" class="ap-pagination__next">
57
+ <a href="?tab={{ tab }}&before={{ before }}" class="ap-pagination__next">
32
58
  {{ __("activitypub.reader.pagination.older") }}
33
59
  </a>
34
60
  </nav>
@@ -43,9 +43,11 @@
43
43
  {% endif %}
44
44
  </div>
45
45
  {% if item.published %}
46
- <time datetime="{{ item.published }}" class="ap-card__timestamp">
47
- {{ item.published | date("PPp") }}
48
- </time>
46
+ <a href="{{ mountPath }}/admin/reader/post?url={{ (item.uid or item.url) | urlencode }}" class="ap-card__timestamp-link" title="{{ __('activitypub.reader.post.title') }}">
47
+ <time datetime="{{ item.published }}" class="ap-card__timestamp">
48
+ {{ item.published | date("PPp") }}
49
+ </time>
50
+ </a>
49
51
  {% endif %}
50
52
  </header>
51
53
 
@@ -53,6 +53,17 @@
53
53
  {{ item.content.text | truncate(200) }}
54
54
  </div>
55
55
  {% endif %}
56
+
57
+ {% if item.type == "reply" or item.type == "mention" %}
58
+ <div class="ap-notification__actions">
59
+ <a href="{{ mountPath }}/admin/reader/compose?replyTo={{ item.uid | urlencode }}" class="ap-notification__reply-btn" title="{{ __('activitypub.reader.actions.reply') }}">
60
+ ↩ {{ __("activitypub.reader.actions.reply") }}
61
+ </a>
62
+ <a href="{{ mountPath }}/admin/reader/post?url={{ item.uid | urlencode }}" class="ap-notification__thread-btn" title="{{ __('activitypub.reader.post.title') }}">
63
+ 💬 {{ __("activitypub.notifications.viewThread") }}
64
+ </a>
65
+ </div>
66
+ {% endif %}
56
67
  </div>
57
68
 
58
69
  {# Timestamp #}