@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 +237 -0
- package/index.js +11 -0
- package/lib/controllers/my-profile.js +251 -0
- package/lib/controllers/reader.js +20 -4
- package/lib/storage/notifications.js +41 -0
- package/locales/en.json +22 -1
- package/package.json +1 -1
- package/views/activitypub-my-profile.njk +85 -0
- package/views/activitypub-notifications.njk +40 -14
- package/views/partials/ap-item-card.njk +5 -3
- package/views/partials/ap-notification-card.njk +11 -0
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 || "
|
|
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
|
-
//
|
|
191
|
-
const
|
|
196
|
+
// Build query options with type filter
|
|
197
|
+
const options = { before, limit };
|
|
198
|
+
if (tab !== "all") {
|
|
199
|
+
options.type = tab;
|
|
200
|
+
}
|
|
192
201
|
|
|
193
|
-
|
|
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.
|
|
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
|
-
{
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
37
|
+
<button type="submit" class="ap-notifications__btn">{{ __("activitypub.notifications.markAllRead") }}</button>
|
|
19
38
|
</form>
|
|
20
|
-
|
|
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
|
-
<
|
|
47
|
-
{{ item.published
|
|
48
|
-
|
|
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 #}
|