@rmdes/indiekit-endpoint-activitypub 1.1.13 → 1.1.15

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.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * OpenGraph link preview cards and AP link interception
3
+ * Styles for link preview cards in the ActivityPub reader
4
+ */
5
+
6
+ /* Link preview container */
7
+ .ap-link-previews {
8
+ margin-top: var(--space-m);
9
+ display: flex;
10
+ flex-direction: column;
11
+ gap: var(--space-s);
12
+ }
13
+
14
+ /* Individual link preview card */
15
+ .ap-link-preview {
16
+ display: flex;
17
+ overflow: hidden;
18
+ border-radius: var(--border-radius-small);
19
+ border: 1px solid var(--color-neutral-lighter);
20
+ background-color: var(--color-offset);
21
+ text-decoration: none;
22
+ color: inherit;
23
+ transition: border-color 0.2s ease;
24
+ }
25
+
26
+ .ap-link-preview:hover {
27
+ border-color: var(--color-primary);
28
+ }
29
+
30
+ /* Text content area (left side) */
31
+ .ap-link-preview__text {
32
+ flex: 1;
33
+ padding: var(--space-s);
34
+ min-width: 0; /* Enable text truncation */
35
+ }
36
+
37
+ .ap-link-preview__title {
38
+ font-weight: 600;
39
+ font-size: 0.875rem;
40
+ color: var(--color-on-background);
41
+ margin: 0;
42
+ overflow: hidden;
43
+ text-overflow: ellipsis;
44
+ white-space: nowrap;
45
+ }
46
+
47
+ .ap-link-preview__desc {
48
+ font-size: 0.75rem;
49
+ color: var(--color-on-offset);
50
+ margin: var(--space-xs) 0 0;
51
+ display: -webkit-box;
52
+ -webkit-line-clamp: 2;
53
+ -webkit-box-orient: vertical;
54
+ overflow: hidden;
55
+ }
56
+
57
+ .ap-link-preview__domain {
58
+ font-size: 0.75rem;
59
+ color: var(--color-neutral);
60
+ margin: var(--space-s) 0 0;
61
+ display: flex;
62
+ align-items: center;
63
+ gap: 0.25rem;
64
+ }
65
+
66
+ .ap-link-preview__favicon {
67
+ width: 1rem;
68
+ height: 1rem;
69
+ display: inline-block;
70
+ }
71
+
72
+ /* Image area (right side) */
73
+ .ap-link-preview__image {
74
+ flex-shrink: 0;
75
+ width: 6rem;
76
+ height: 6rem;
77
+ }
78
+
79
+ .ap-link-preview__image img {
80
+ width: 100%;
81
+ height: 100%;
82
+ object-fit: cover;
83
+ }
84
+
85
+ /* Responsive - larger images on desktop */
86
+ @media (min-width: 640px) {
87
+ .ap-link-preview__image {
88
+ width: 8rem;
89
+ height: 8rem;
90
+ }
91
+
92
+ .ap-link-preview__title {
93
+ font-size: 1rem;
94
+ }
95
+
96
+ .ap-link-preview__desc {
97
+ font-size: 0.875rem;
98
+ }
99
+ }
100
+
101
+ /* Post detail thread view */
102
+ .ap-post-detail__back {
103
+ margin-bottom: var(--space-m);
104
+ }
105
+
106
+ .ap-post-detail__back-link {
107
+ font-size: 0.875rem;
108
+ color: var(--color-primary);
109
+ text-decoration: none;
110
+ }
111
+
112
+ .ap-post-detail__back-link:hover {
113
+ text-decoration: underline;
114
+ }
115
+
116
+ .ap-post-detail__section-title {
117
+ font-size: 0.875rem;
118
+ font-weight: 600;
119
+ color: var(--color-neutral);
120
+ text-transform: uppercase;
121
+ letter-spacing: 0.05em;
122
+ margin: var(--space-l) 0 var(--space-s);
123
+ padding-bottom: var(--space-xs);
124
+ border-bottom: 1px solid var(--color-neutral-lighter);
125
+ }
126
+
127
+ .ap-post-detail__main {
128
+ margin: var(--space-m) 0;
129
+ }
130
+
131
+ .ap-post-detail__parents,
132
+ .ap-post-detail__replies {
133
+ margin: var(--space-m) 0;
134
+ }
135
+
136
+ .ap-post-detail__parent-item,
137
+ .ap-post-detail__reply-item {
138
+ margin-bottom: var(--space-s);
139
+ }
140
+
141
+ /* Thread connector line between parent posts */
142
+ .ap-post-detail__parents .ap-post-detail__parent-item {
143
+ position: relative;
144
+ padding-left: var(--space-m);
145
+ border-left: 2px solid var(--color-neutral-lighter);
146
+ }
147
+
148
+ /* Main post highlight */
149
+ .ap-post-detail__main .ap-card {
150
+ border-left-width: 3px;
151
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Client-side AP link interception for internal navigation
3
+ * Redirects ActivityPub links to internal reader views
4
+ */
5
+
6
+ (function () {
7
+ "use strict";
8
+
9
+ // Fediverse URL patterns that should open internally
10
+ const AP_URL_PATTERN =
11
+ /\/@[\w.-]+\/\d+|\/@[\w.-]+\/statuses\/[\w]+|\/users\/[\w.-]+\/statuses\/\d+|\/objects\/[\w-]+|\/notice\/[\w]+|\/notes\/[\w]+|\/post\/\d+|\/comment\/\d+|\/p\/[\w.-]+\/\d+/;
12
+
13
+ // Get mount path from DOM
14
+ function getMountPath() {
15
+ // Look for data-mount-path on reader container or header
16
+ const container = document.querySelector(
17
+ "[data-mount-path]",
18
+ );
19
+ return container ? container.dataset.mountPath : "/activitypub";
20
+ }
21
+
22
+ // Check if a link should be intercepted
23
+ function shouldInterceptLink(link) {
24
+ const href = link.getAttribute("href");
25
+ if (!href) return null;
26
+
27
+ const classes = link.className || "";
28
+
29
+ // Mention links → profile view
30
+ if (classes.includes("mention")) {
31
+ return { type: "profile", url: href };
32
+ }
33
+
34
+ // AP object URL patterns → post detail view
35
+ if (AP_URL_PATTERN.test(href)) {
36
+ return { type: "post", url: href };
37
+ }
38
+
39
+ return null;
40
+ }
41
+
42
+ // Handle link click
43
+ function handleLinkClick(event) {
44
+ const link = event.target.closest("a");
45
+ if (!link) return;
46
+
47
+ // Only intercept links inside post content
48
+ const contentDiv = link.closest(".ap-card__content");
49
+ if (!contentDiv) return;
50
+
51
+ const interception = shouldInterceptLink(link);
52
+ if (!interception) return;
53
+
54
+ // Prevent default navigation
55
+ event.preventDefault();
56
+
57
+ const mountPath = getMountPath();
58
+ const encodedUrl = encodeURIComponent(interception.url);
59
+
60
+ if (interception.type === "profile") {
61
+ window.location.href = `${mountPath}/admin/reader/profile?url=${encodedUrl}`;
62
+ } else if (interception.type === "post") {
63
+ window.location.href = `${mountPath}/admin/reader/post?url=${encodedUrl}`;
64
+ }
65
+ }
66
+
67
+ // Initialize on DOM ready
68
+ function init() {
69
+ // Use event delegation on timeline container
70
+ const timeline = document.querySelector(".ap-timeline");
71
+ if (timeline) {
72
+ timeline.addEventListener("click", handleLinkClick);
73
+ }
74
+
75
+ // Also set up on post detail view
76
+ const postDetail = document.querySelector(".ap-post-detail");
77
+ if (postDetail) {
78
+ postDetail.addEventListener("click", handleLinkClick);
79
+ }
80
+ }
81
+
82
+ // Run on DOMContentLoaded or immediately if already loaded
83
+ if (document.readyState === "loading") {
84
+ document.addEventListener("DOMContentLoaded", init);
85
+ } else {
86
+ init();
87
+ }
88
+ })();
package/assets/reader.css CHANGED
@@ -746,6 +746,37 @@
746
746
  Notifications
747
747
  ========================================================================== */
748
748
 
749
+ /* Notifications Toolbar */
750
+ .ap-notifications__toolbar {
751
+ display: flex;
752
+ gap: var(--space-s);
753
+ margin-bottom: var(--space-m);
754
+ }
755
+
756
+ .ap-notifications__btn {
757
+ background: var(--color-offset);
758
+ border: var(--border-width-thin) solid var(--color-outline);
759
+ border-radius: var(--border-radius-small);
760
+ color: var(--color-on-background);
761
+ cursor: pointer;
762
+ font-size: var(--font-size-s);
763
+ padding: var(--space-xs) var(--space-m);
764
+ transition: all 0.2s ease;
765
+ }
766
+
767
+ .ap-notifications__btn:hover {
768
+ background: var(--color-offset-variant);
769
+ border-color: var(--color-outline-variant);
770
+ }
771
+
772
+ .ap-notifications__btn--danger {
773
+ color: var(--color-red45);
774
+ }
775
+
776
+ .ap-notifications__btn--danger:hover {
777
+ border-color: var(--color-red45);
778
+ }
779
+
749
780
  .ap-notification {
750
781
  align-items: flex-start;
751
782
  background: var(--color-offset);
@@ -754,6 +785,7 @@
754
785
  display: flex;
755
786
  gap: var(--space-s);
756
787
  padding: var(--space-m);
788
+ position: relative;
757
789
  }
758
790
 
759
791
  .ap-notification--unread {
@@ -803,6 +835,29 @@
803
835
  font-size: var(--font-size-xs);
804
836
  }
805
837
 
838
+ .ap-notification__dismiss {
839
+ position: absolute;
840
+ right: var(--space-xs);
841
+ top: var(--space-xs);
842
+ }
843
+
844
+ .ap-notification__dismiss-btn {
845
+ background: transparent;
846
+ border: 0;
847
+ border-radius: var(--border-radius-small);
848
+ color: var(--color-on-offset);
849
+ cursor: pointer;
850
+ font-size: var(--font-size-m);
851
+ line-height: 1;
852
+ padding: 2px 6px;
853
+ transition: all 0.2s ease;
854
+ }
855
+
856
+ .ap-notification__dismiss-btn:hover {
857
+ background: var(--color-offset-variant);
858
+ color: var(--color-red45);
859
+ }
860
+
806
861
  /* ==========================================================================
807
862
  Remote Profile
808
863
  ========================================================================== */
package/index.js CHANGED
@@ -12,11 +12,15 @@ import { dashboardController } from "./lib/controllers/dashboard.js";
12
12
  import {
13
13
  readerController,
14
14
  notificationsController,
15
+ markAllNotificationsReadController,
16
+ clearAllNotificationsController,
17
+ deleteNotificationController,
15
18
  composeController,
16
19
  submitComposeController,
17
20
  remoteProfileController,
18
21
  followController,
19
22
  unfollowController,
23
+ postDetailController,
20
24
  } from "./lib/controllers/reader.js";
21
25
  import {
22
26
  likeController,
@@ -78,6 +82,7 @@ const defaults = {
78
82
  parallelWorkers: 5,
79
83
  actorType: "Person",
80
84
  timelineRetention: 1000,
85
+ notificationRetentionDays: 30,
81
86
  };
82
87
 
83
88
  export default class ActivityPubEndpoint {
@@ -188,6 +193,9 @@ export default class ActivityPubEndpoint {
188
193
  router.get("/", dashboardController(mp));
189
194
  router.get("/admin/reader", readerController(mp));
190
195
  router.get("/admin/reader/notifications", notificationsController(mp));
196
+ router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp));
197
+ router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp));
198
+ router.post("/admin/reader/notifications/delete", deleteNotificationController(mp));
191
199
  router.get("/admin/reader/compose", composeController(mp, this));
192
200
  router.post("/admin/reader/compose", submitComposeController(mp, this));
193
201
  router.post("/admin/reader/like", likeController(mp, this));
@@ -195,6 +203,7 @@ export default class ActivityPubEndpoint {
195
203
  router.post("/admin/reader/boost", boostController(mp, this));
196
204
  router.post("/admin/reader/unboost", unboostController(mp, this));
197
205
  router.get("/admin/reader/profile", remoteProfileController(mp, this));
206
+ router.get("/admin/reader/post", postDetailController(mp, this));
198
207
  router.post("/admin/reader/follow", followController(mp, this));
199
208
  router.post("/admin/reader/unfollow", unfollowController(mp, this));
200
209
  router.get("/admin/reader/moderation", moderationController(mp));
@@ -833,6 +842,15 @@ export default class ActivityPubEndpoint {
833
842
  { background: true },
834
843
  );
835
844
 
845
+ // TTL index for notification cleanup
846
+ const notifRetention = this.options.notificationRetentionDays;
847
+ if (notifRetention > 0) {
848
+ this._collections.ap_notifications.createIndex(
849
+ { createdAt: 1 },
850
+ { expireAfterSeconds: notifRetention * 86_400 },
851
+ );
852
+ }
853
+
836
854
  this._collections.ap_muted.createIndex(
837
855
  { url: 1 },
838
856
  { unique: true, sparse: true, background: true },
@@ -0,0 +1,300 @@
1
+ // Post detail controller — view individual AP posts/notes/articles
2
+ import { Article, Note, Person, Service, Application } from "@fedify/fedify";
3
+ import { getToken } from "../csrf.js";
4
+ import { extractObjectData } from "../timeline-store.js";
5
+ import { getCached, setCache } from "../lookup-cache.js";
6
+
7
+ // Load parent posts (inReplyTo chain) up to maxDepth levels
8
+ async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxDepth = 5) {
9
+ const parents = [];
10
+ let currentUrl = parentUrl;
11
+ let depth = 0;
12
+
13
+ while (currentUrl && depth < maxDepth) {
14
+ depth++;
15
+
16
+ // Check timeline first
17
+ let parent = timelineCol
18
+ ? await timelineCol.findOne({
19
+ $or: [{ uid: currentUrl }, { url: currentUrl }],
20
+ })
21
+ : null;
22
+
23
+ if (!parent) {
24
+ // Fetch via lookupObject
25
+ const cached = getCached(currentUrl);
26
+ let object = cached;
27
+
28
+ if (!object) {
29
+ try {
30
+ object = await ctx.lookupObject(new URL(currentUrl), {
31
+ documentLoader,
32
+ });
33
+ if (object) {
34
+ setCache(currentUrl, object);
35
+ }
36
+ } catch {
37
+ break; // Stop on error
38
+ }
39
+ }
40
+
41
+ if (!object || !(object instanceof Note || object instanceof Article)) {
42
+ break;
43
+ }
44
+
45
+ try {
46
+ parent = await extractObjectData(object);
47
+ } catch {
48
+ break;
49
+ }
50
+ }
51
+
52
+ if (parent) {
53
+ parents.unshift(parent); // Add to beginning (chronological order)
54
+ currentUrl = parent.inReplyTo; // Continue up the chain
55
+ } else {
56
+ break;
57
+ }
58
+ }
59
+
60
+ return parents;
61
+ }
62
+
63
+ // Load replies collection (best-effort)
64
+ async function loadReplies(object, ctx, documentLoader, timelineCol, maxReplies = 10) {
65
+ const replies = [];
66
+
67
+ try {
68
+ const repliesCollection = await object.getReplies({ documentLoader });
69
+ if (!repliesCollection) return replies;
70
+
71
+ let items = [];
72
+ try {
73
+ items = await repliesCollection.getItems({ documentLoader });
74
+ } catch {
75
+ return replies;
76
+ }
77
+
78
+ for (const replyItem of items.slice(0, maxReplies)) {
79
+ try {
80
+ const replyUrl = replyItem.id?.href || replyItem.url?.href;
81
+ if (!replyUrl) continue;
82
+
83
+ // Check timeline first
84
+ let reply = timelineCol
85
+ ? await timelineCol.findOne({
86
+ $or: [{ uid: replyUrl }, { url: replyUrl }],
87
+ })
88
+ : null;
89
+
90
+ if (!reply) {
91
+ // Extract from the item we already have
92
+ if (replyItem instanceof Note || replyItem instanceof Article) {
93
+ reply = await extractObjectData(replyItem);
94
+ }
95
+ }
96
+
97
+ if (reply) {
98
+ replies.push(reply);
99
+ }
100
+ } catch {
101
+ continue; // Skip failed replies
102
+ }
103
+ }
104
+ } catch {
105
+ // getReplies() failed or not available
106
+ }
107
+
108
+ return replies;
109
+ }
110
+
111
+ // GET /admin/reader/post — Show post detail view
112
+ export function postDetailController(mountPath, plugin) {
113
+ return async (request, response, next) => {
114
+ try {
115
+ const { application } = request.app.locals;
116
+ const objectUrl = request.query.url;
117
+
118
+ if (!objectUrl || typeof objectUrl !== "string") {
119
+ return response.status(400).render("error", {
120
+ title: "Error",
121
+ content: "Missing post URL",
122
+ });
123
+ }
124
+
125
+ // Validate URL format
126
+ try {
127
+ new URL(objectUrl);
128
+ } catch {
129
+ return response.status(400).render("error", {
130
+ title: "Error",
131
+ content: "Invalid post URL",
132
+ });
133
+ }
134
+
135
+ if (!plugin._federation) {
136
+ return response.status(503).render("error", {
137
+ title: "Error",
138
+ content: "Federation not initialized",
139
+ });
140
+ }
141
+
142
+ const timelineCol = application?.collections?.get("ap_timeline");
143
+ const interactionsCol =
144
+ application?.collections?.get("ap_interactions");
145
+
146
+ // Check local timeline first (optimization)
147
+ let timelineItem = null;
148
+ if (timelineCol) {
149
+ timelineItem = await timelineCol.findOne({
150
+ $or: [{ uid: objectUrl }, { url: objectUrl }],
151
+ });
152
+ }
153
+
154
+ let object = null;
155
+
156
+ if (!timelineItem) {
157
+ // Not in local timeline — fetch via lookupObject
158
+ const handle = plugin.options.actor.handle;
159
+ const ctx = plugin._federation.createContext(
160
+ new URL(plugin._publicationUrl),
161
+ { handle, publicationUrl: plugin._publicationUrl },
162
+ );
163
+
164
+ const documentLoader = await ctx.getDocumentLoader({
165
+ identifier: handle,
166
+ });
167
+
168
+ // Check cache first
169
+ const cached = getCached(objectUrl);
170
+ if (cached) {
171
+ object = cached;
172
+ } else {
173
+ try {
174
+ object = await ctx.lookupObject(new URL(objectUrl), {
175
+ documentLoader,
176
+ });
177
+ if (object) {
178
+ setCache(objectUrl, object);
179
+ }
180
+ } catch (error) {
181
+ console.warn(
182
+ `[post-detail] lookupObject failed for ${objectUrl}:`,
183
+ error.message,
184
+ );
185
+ }
186
+ }
187
+
188
+ if (!object) {
189
+ return response.status(404).render("activitypub-post-detail", {
190
+ title: response.locals.__("activitypub.reader.post.title"),
191
+ notFound: true, objectUrl, mountPath,
192
+ item: null, interactionMap: {}, csrfToken: null,
193
+ parentPosts: [], replyPosts: [],
194
+ });
195
+ }
196
+
197
+ // If it's an actor (Person, Service, Application), redirect to profile
198
+ if (
199
+ object instanceof Person ||
200
+ object instanceof Service ||
201
+ object instanceof Application
202
+ ) {
203
+ return response.redirect(
204
+ `${mountPath}/admin/reader/profile?url=${encodeURIComponent(objectUrl)}`,
205
+ );
206
+ }
207
+
208
+ // Extract timeline item data from the Fedify object
209
+ if (object instanceof Note || object instanceof Article) {
210
+ try {
211
+ timelineItem = await extractObjectData(object);
212
+ } catch (error) {
213
+ console.error(`[post-detail] extractObjectData failed for ${objectUrl}:`, error.message);
214
+ return response.status(500).render("error", {
215
+ title: "Error",
216
+ content: "Failed to extract post data",
217
+ });
218
+ }
219
+ } else {
220
+ return response.status(400).render("error", {
221
+ title: "Error",
222
+ content: "Object is not a viewable post (must be Note or Article)",
223
+ });
224
+ }
225
+ }
226
+
227
+ // Build interaction state for this post
228
+ const interactionMap = {};
229
+ if (interactionsCol && timelineItem) {
230
+ const uid = timelineItem.uid;
231
+ const displayUrl = timelineItem.url || timelineItem.originalUrl;
232
+
233
+ const interactions = await interactionsCol
234
+ .find({
235
+ $or: [{ objectUrl: uid }, { objectUrl: displayUrl }],
236
+ })
237
+ .toArray();
238
+
239
+ for (const interaction of interactions) {
240
+ const key = uid;
241
+ if (!interactionMap[key]) {
242
+ interactionMap[key] = {};
243
+ }
244
+ interactionMap[key][interaction.type] = true;
245
+ }
246
+ }
247
+
248
+ // Load thread (parent chain + replies) with timeout
249
+ let parentPosts = [];
250
+ let replyPosts = [];
251
+
252
+ try {
253
+ const handle = plugin.options.actor.handle;
254
+ const ctx = plugin._federation.createContext(
255
+ new URL(plugin._publicationUrl),
256
+ { handle, publicationUrl: plugin._publicationUrl },
257
+ );
258
+
259
+ const documentLoader = await ctx.getDocumentLoader({
260
+ identifier: handle,
261
+ });
262
+
263
+ const threadPromise = Promise.all([
264
+ // Load parent chain
265
+ timelineItem.inReplyTo
266
+ ? loadParentChain(ctx, documentLoader, timelineCol, timelineItem.inReplyTo)
267
+ : Promise.resolve([]),
268
+ // Load replies (if object is available)
269
+ object
270
+ ? loadReplies(object, ctx, documentLoader, timelineCol)
271
+ : Promise.resolve([]),
272
+ ]);
273
+
274
+ // 15-second timeout for thread loading
275
+ const timeout = new Promise((resolve) =>
276
+ setTimeout(() => resolve([[], []]), 15000),
277
+ );
278
+
279
+ [parentPosts, replyPosts] = await Promise.race([threadPromise, timeout]);
280
+ } catch (error) {
281
+ console.error("[post-detail] Thread loading failed:", error.message);
282
+ // Continue with empty thread
283
+ }
284
+
285
+ const csrfToken = getToken(request.session);
286
+
287
+ response.render("activitypub-post-detail", {
288
+ title: response.locals.__("activitypub.reader.post.title"),
289
+ item: timelineItem,
290
+ interactionMap,
291
+ csrfToken,
292
+ mountPath,
293
+ parentPosts,
294
+ replyPosts,
295
+ });
296
+ } catch (error) {
297
+ next(error);
298
+ }
299
+ };
300
+ }