@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.
- package/assets/reader-links.css +151 -0
- package/assets/reader-links.js +88 -0
- package/assets/reader.css +55 -0
- package/index.js +18 -0
- package/lib/controllers/post-detail.js +300 -0
- package/lib/controllers/reader.js +96 -6
- package/lib/inbox-listeners.js +9 -0
- package/lib/lookup-cache.js +38 -0
- package/lib/og-unfurl.js +250 -0
- package/lib/storage/notifications.js +21 -0
- package/lib/storage/timeline.js +1 -0
- package/locales/en.json +18 -1
- package/package.json +3 -2
- package/views/activitypub-notifications.njk +14 -0
- package/views/activitypub-post-detail.njk +61 -0
- package/views/activitypub-reader.njk +1 -1
- package/views/layouts/ap-reader.njk +4 -0
- package/views/partials/ap-item-card.njk +8 -2
- package/views/partials/ap-link-preview.njk +34 -0
- package/views/partials/ap-notification-card.njk +7 -0
|
@@ -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
|
+
}
|