@rmdes/indiekit-endpoint-activitypub 1.1.13 → 1.1.14
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/index.js +2 -0
- package/lib/controllers/post-detail.js +300 -0
- package/lib/controllers/reader.js +1 -0
- package/lib/inbox-listeners.js +9 -0
- package/lib/lookup-cache.js +38 -0
- package/lib/og-unfurl.js +250 -0
- package/lib/storage/timeline.js +1 -0
- package/locales/en.json +13 -0
- package/package.json +3 -2
- 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
|
@@ -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/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
remoteProfileController,
|
|
18
18
|
followController,
|
|
19
19
|
unfollowController,
|
|
20
|
+
postDetailController,
|
|
20
21
|
} from "./lib/controllers/reader.js";
|
|
21
22
|
import {
|
|
22
23
|
likeController,
|
|
@@ -195,6 +196,7 @@ export default class ActivityPubEndpoint {
|
|
|
195
196
|
router.post("/admin/reader/boost", boostController(mp, this));
|
|
196
197
|
router.post("/admin/reader/unboost", unboostController(mp, this));
|
|
197
198
|
router.get("/admin/reader/profile", remoteProfileController(mp, this));
|
|
199
|
+
router.get("/admin/reader/post", postDetailController(mp, this));
|
|
198
200
|
router.post("/admin/reader/follow", followController(mp, this));
|
|
199
201
|
router.post("/admin/reader/unfollow", unfollowController(mp, this));
|
|
200
202
|
router.get("/admin/reader/moderation", moderationController(mp));
|
|
@@ -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
|
+
}
|
package/lib/inbox-listeners.js
CHANGED
|
@@ -27,6 +27,7 @@ import { logActivity as logActivityShared } from "./activity-log.js";
|
|
|
27
27
|
import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";
|
|
28
28
|
import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
|
|
29
29
|
import { addNotification } from "./storage/notifications.js";
|
|
30
|
+
import { fetchAndStorePreviews } from "./og-unfurl.js";
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Register all inbox listeners on a federation's inbox chain.
|
|
@@ -450,6 +451,14 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
450
451
|
actorFallback: actorObj,
|
|
451
452
|
});
|
|
452
453
|
await addTimelineItem(collections, timelineItem);
|
|
454
|
+
|
|
455
|
+
// Fire-and-forget OG unfurling for notes and articles (not boosts)
|
|
456
|
+
if (timelineItem.type === "note" || timelineItem.type === "article") {
|
|
457
|
+
fetchAndStorePreviews(collections, timelineItem.uid, timelineItem.content.html)
|
|
458
|
+
.catch((error) => {
|
|
459
|
+
console.error(`[inbox] OG unfurl failed for ${timelineItem.uid}:`, error);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
453
462
|
} catch (error) {
|
|
454
463
|
// Log extraction errors but don't fail the entire handler
|
|
455
464
|
console.error("Failed to store timeline item:", error);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple in-memory LRU cache for lookupObject results
|
|
3
|
+
* Max 100 entries, 5-minute TTL
|
|
4
|
+
* @module lookup-cache
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const lookupCache = new Map();
|
|
8
|
+
const CACHE_MAX_SIZE = 100;
|
|
9
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get a cached lookup result
|
|
13
|
+
* @param {string} url - URL key
|
|
14
|
+
* @returns {*} Cached data or null
|
|
15
|
+
*/
|
|
16
|
+
export function getCached(url) {
|
|
17
|
+
const entry = lookupCache.get(url);
|
|
18
|
+
if (!entry) return null;
|
|
19
|
+
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
|
|
20
|
+
lookupCache.delete(url);
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return entry.data;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Store a lookup result in cache
|
|
28
|
+
* @param {string} url - URL key
|
|
29
|
+
* @param {*} data - Data to cache
|
|
30
|
+
*/
|
|
31
|
+
export function setCache(url, data) {
|
|
32
|
+
// Evict oldest entry if at max size
|
|
33
|
+
if (lookupCache.size >= CACHE_MAX_SIZE) {
|
|
34
|
+
const firstKey = lookupCache.keys().next().value;
|
|
35
|
+
lookupCache.delete(firstKey);
|
|
36
|
+
}
|
|
37
|
+
lookupCache.set(url, { data, timestamp: Date.now() });
|
|
38
|
+
}
|
package/lib/og-unfurl.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenGraph metadata fetching with concurrency limiting
|
|
3
|
+
* @module og-unfurl
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { unfurl } from "unfurl.js";
|
|
7
|
+
|
|
8
|
+
const USER_AGENT =
|
|
9
|
+
"Mozilla/5.0 (compatible; Indiekit/1.0; +https://getindiekit.com)";
|
|
10
|
+
const TIMEOUT_MS = 10000; // 10 seconds per URL
|
|
11
|
+
const MAX_CONCURRENT = 3; // Lower than theme's 5 (inbox context)
|
|
12
|
+
const MAX_PREVIEWS = 3; // Max previews per post
|
|
13
|
+
|
|
14
|
+
// Concurrency limiter — prevents overwhelming outbound network
|
|
15
|
+
let activeRequests = 0;
|
|
16
|
+
const queue = [];
|
|
17
|
+
|
|
18
|
+
function runNext() {
|
|
19
|
+
if (queue.length === 0 || activeRequests >= MAX_CONCURRENT) return;
|
|
20
|
+
activeRequests++;
|
|
21
|
+
const { resolve: res, fn } = queue.shift();
|
|
22
|
+
fn()
|
|
23
|
+
.then(res)
|
|
24
|
+
.finally(() => {
|
|
25
|
+
activeRequests--;
|
|
26
|
+
runNext();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function throttled(fn) {
|
|
31
|
+
return new Promise((res) => {
|
|
32
|
+
queue.push({ resolve: res, fn });
|
|
33
|
+
runNext();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function extractDomain(url) {
|
|
38
|
+
try {
|
|
39
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
40
|
+
} catch {
|
|
41
|
+
return url;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if a URL points to a private/reserved IP or localhost (SSRF protection)
|
|
47
|
+
* @param {string} url - URL to check
|
|
48
|
+
* @returns {boolean} True if URL targets a private network
|
|
49
|
+
*/
|
|
50
|
+
function isPrivateUrl(url) {
|
|
51
|
+
try {
|
|
52
|
+
const urlObj = new URL(url);
|
|
53
|
+
const hostname = urlObj.hostname.toLowerCase();
|
|
54
|
+
|
|
55
|
+
// Block non-http(s) schemes
|
|
56
|
+
if (urlObj.protocol !== "http:" && urlObj.protocol !== "https:") {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Block localhost variants
|
|
61
|
+
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]") {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Block private IPv4 ranges
|
|
66
|
+
const ipv4Match = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
67
|
+
if (ipv4Match) {
|
|
68
|
+
const [, a, b] = ipv4Match.map(Number);
|
|
69
|
+
if (a === 10) return true; // 10.0.0.0/8
|
|
70
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
|
|
71
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16
|
|
72
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local / cloud metadata)
|
|
73
|
+
if (a === 127) return true; // 127.0.0.0/8
|
|
74
|
+
if (a === 0) return true; // 0.0.0.0/8
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Block IPv6 private ranges (basic check)
|
|
78
|
+
if (hostname.startsWith("[fc") || hostname.startsWith("[fd") || hostname.startsWith("[fe80")) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return false;
|
|
83
|
+
} catch {
|
|
84
|
+
return true; // Invalid URL, treat as private
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract links from HTML content
|
|
90
|
+
* @param {string} html - Sanitized HTML content
|
|
91
|
+
* @returns {Array<{url: string, classes: string}>} Links with their class attributes
|
|
92
|
+
*/
|
|
93
|
+
function extractLinks(html) {
|
|
94
|
+
if (!html) return [];
|
|
95
|
+
|
|
96
|
+
const links = [];
|
|
97
|
+
// Match complete <a> tags and extract href + class from anywhere in attributes
|
|
98
|
+
const anchorRegex = /<a\s([^>]+)>/gi;
|
|
99
|
+
|
|
100
|
+
let match;
|
|
101
|
+
while ((match = anchorRegex.exec(html)) !== null) {
|
|
102
|
+
const attrs = match[1];
|
|
103
|
+
const hrefMatch = attrs.match(/href="([^"]+)"/);
|
|
104
|
+
const classMatch = attrs.match(/class="([^"]+)"/);
|
|
105
|
+
if (hrefMatch) {
|
|
106
|
+
links.push({ url: hrefMatch[1], classes: classMatch ? classMatch[1] : "" });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return links;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check if URL is likely an ActivityPub object or media file
|
|
115
|
+
* @param {string} url - URL to check
|
|
116
|
+
* @returns {boolean} True if URL should be skipped
|
|
117
|
+
*/
|
|
118
|
+
function shouldSkipUrl(url) {
|
|
119
|
+
try {
|
|
120
|
+
const urlObj = new URL(url);
|
|
121
|
+
|
|
122
|
+
// SSRF protection — skip private/internal URLs
|
|
123
|
+
if (isPrivateUrl(url)) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Skip media extensions
|
|
128
|
+
const mediaExtensions = /\.(jpg|jpeg|png|gif|webp|mp4|webm|mov|mp3|wav|ogg)$/i;
|
|
129
|
+
if (mediaExtensions.test(urlObj.pathname)) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Skip common AP object patterns (heuristic - not exhaustive)
|
|
134
|
+
const apPatterns = [
|
|
135
|
+
/\/@[\w.-]+\/\d+/, // Mastodon /@user/12345
|
|
136
|
+
/\/@[\w.-]+\/statuses\/[\w]+/, // GoToSocial /@user/statuses/id
|
|
137
|
+
/\/users\/[\w.-]+\/statuses\/\d+/, // Mastodon/Pleroma /users/user/statuses/12345
|
|
138
|
+
/\/objects\/[\w-]+/, // Pleroma/Akkoma /objects/uuid
|
|
139
|
+
/\/notice\/[\w]+/, // Pleroma /notice/id
|
|
140
|
+
/\/notes\/[\w]+/, // Misskey /notes/id
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
return apPatterns.some((pattern) => pattern.test(urlObj.pathname));
|
|
144
|
+
} catch {
|
|
145
|
+
return true; // Invalid URL, skip
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Fetch OpenGraph metadata for external links in HTML content
|
|
151
|
+
* @param {string} html - Sanitized HTML content
|
|
152
|
+
* @returns {Promise<Array<{url: string, title: string, description: string, image: string, favicon: string, domain: string, fetchedAt: string}>>} Link preview objects
|
|
153
|
+
*/
|
|
154
|
+
export async function fetchLinkPreviews(html) {
|
|
155
|
+
if (!html) return [];
|
|
156
|
+
|
|
157
|
+
const links = extractLinks(html);
|
|
158
|
+
|
|
159
|
+
// Filter links
|
|
160
|
+
const urlsToFetch = links
|
|
161
|
+
.filter((link) => {
|
|
162
|
+
// Skip mention links (class="mention")
|
|
163
|
+
if (link.classes.includes("mention")) return false;
|
|
164
|
+
|
|
165
|
+
// Skip hashtag links (class="hashtag")
|
|
166
|
+
if (link.classes.includes("hashtag")) return false;
|
|
167
|
+
|
|
168
|
+
// Skip AP object URLs and media files
|
|
169
|
+
if (shouldSkipUrl(link.url)) return false;
|
|
170
|
+
|
|
171
|
+
return true;
|
|
172
|
+
})
|
|
173
|
+
.map((link) => link.url)
|
|
174
|
+
.filter((url, index, self) => self.indexOf(url) === index) // Dedupe
|
|
175
|
+
.slice(0, MAX_PREVIEWS); // Cap at max
|
|
176
|
+
|
|
177
|
+
if (urlsToFetch.length === 0) return [];
|
|
178
|
+
|
|
179
|
+
// Fetch metadata for each URL (throttled)
|
|
180
|
+
const previews = await Promise.all(
|
|
181
|
+
urlsToFetch.map(async (url) => {
|
|
182
|
+
const metadata = await throttled(async () => {
|
|
183
|
+
try {
|
|
184
|
+
return await unfurl(url, {
|
|
185
|
+
timeout: TIMEOUT_MS,
|
|
186
|
+
headers: { "User-Agent": USER_AGENT },
|
|
187
|
+
});
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.warn(`[og-unfurl] Failed to fetch ${url}: ${error.message}`);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (!metadata) return null;
|
|
195
|
+
|
|
196
|
+
const og = metadata.open_graph || {};
|
|
197
|
+
const tc = metadata.twitter_card || {};
|
|
198
|
+
|
|
199
|
+
const title = og.title || tc.title || metadata.title || extractDomain(url);
|
|
200
|
+
const description = og.description || tc.description || metadata.description || "";
|
|
201
|
+
const image = og.images?.[0]?.url || tc.images?.[0]?.url || null;
|
|
202
|
+
const favicon = metadata.favicon || null;
|
|
203
|
+
const domain = extractDomain(url);
|
|
204
|
+
|
|
205
|
+
// Truncate description
|
|
206
|
+
const maxDesc = 160;
|
|
207
|
+
const desc =
|
|
208
|
+
description.length > maxDesc
|
|
209
|
+
? description.slice(0, maxDesc).trim() + "\u2026"
|
|
210
|
+
: description;
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
url,
|
|
214
|
+
title,
|
|
215
|
+
description: desc,
|
|
216
|
+
image,
|
|
217
|
+
favicon,
|
|
218
|
+
domain,
|
|
219
|
+
fetchedAt: new Date().toISOString(),
|
|
220
|
+
};
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// Filter out failed fetches (null results)
|
|
225
|
+
return previews.filter((preview) => preview !== null);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Fetch link previews and store them on a timeline item
|
|
230
|
+
* Fire-and-forget — caller does NOT await. Errors are caught and logged.
|
|
231
|
+
* @param {object} collections - MongoDB collections
|
|
232
|
+
* @param {string} uid - Timeline item UID
|
|
233
|
+
* @param {string} html - Post content HTML
|
|
234
|
+
* @returns {Promise<void>}
|
|
235
|
+
*/
|
|
236
|
+
export async function fetchAndStorePreviews(collections, uid, html) {
|
|
237
|
+
try {
|
|
238
|
+
const linkPreviews = await fetchLinkPreviews(html);
|
|
239
|
+
|
|
240
|
+
await collections.ap_timeline.updateOne(
|
|
241
|
+
{ uid },
|
|
242
|
+
{ $set: { linkPreviews } },
|
|
243
|
+
);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
// Fire-and-forget — log errors but don't throw
|
|
246
|
+
console.error(
|
|
247
|
+
`[og-unfurl] Failed to store previews for ${uid}: ${error.message}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
package/lib/storage/timeline.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* @param {object} [item.boostedBy] - { name, url, photo, handle } for boosts
|
|
25
25
|
* @param {Date} [item.boostedAt] - Boost timestamp
|
|
26
26
|
* @param {string} [item.originalUrl] - Original post URL for boosts
|
|
27
|
+
* @param {Array<{url: string, title: string, description: string, image: string, favicon: string, domain: string, fetchedAt: string}>} [item.linkPreviews] - OpenGraph link previews for external links in content
|
|
27
28
|
* @param {string} item.createdAt - ISO string creation timestamp
|
|
28
29
|
* @returns {Promise<object>} Created or existing item
|
|
29
30
|
*/
|
package/locales/en.json
CHANGED
|
@@ -176,6 +176,19 @@
|
|
|
176
176
|
"boosted": "Boosted",
|
|
177
177
|
"likeError": "Could not like this post",
|
|
178
178
|
"boostError": "Could not boost this post"
|
|
179
|
+
},
|
|
180
|
+
"post": {
|
|
181
|
+
"title": "Post Detail",
|
|
182
|
+
"notFound": "Post not found or no longer available.",
|
|
183
|
+
"openExternal": "Open on original instance",
|
|
184
|
+
"parentPosts": "Thread",
|
|
185
|
+
"replies": "Replies",
|
|
186
|
+
"back": "Back to timeline",
|
|
187
|
+
"loadingThread": "Loading thread...",
|
|
188
|
+
"threadError": "Could not load full thread"
|
|
189
|
+
},
|
|
190
|
+
"linkPreview": {
|
|
191
|
+
"label": "Link preview"
|
|
179
192
|
}
|
|
180
193
|
}
|
|
181
194
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.14",
|
|
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",
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"@js-temporal/polyfill": "^0.5.0",
|
|
44
44
|
"express": "^5.0.0",
|
|
45
45
|
"ioredis": "^5.9.3",
|
|
46
|
-
"sanitize-html": "^2.13.1"
|
|
46
|
+
"sanitize-html": "^2.13.1",
|
|
47
|
+
"unfurl.js": "^6.4.0"
|
|
47
48
|
},
|
|
48
49
|
"peerDependencies": {
|
|
49
50
|
"@indiekit/error": "^1.0.0-beta.25",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{% extends "layouts/ap-reader.njk" %}
|
|
2
|
+
|
|
3
|
+
{% from "heading/macro.njk" import heading with context %}
|
|
4
|
+
|
|
5
|
+
{% block readercontent %}
|
|
6
|
+
{{ heading({
|
|
7
|
+
text: title,
|
|
8
|
+
level: 1,
|
|
9
|
+
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
|
|
10
|
+
}) }}
|
|
11
|
+
|
|
12
|
+
<div class="ap-post-detail" data-mount-path="{{ mountPath }}">
|
|
13
|
+
{# Back button #}
|
|
14
|
+
<div class="ap-post-detail__back">
|
|
15
|
+
<a href="{{ mountPath }}/admin/reader" class="ap-post-detail__back-link">
|
|
16
|
+
← {{ __("activitypub.reader.post.back") }}
|
|
17
|
+
</a>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
{% if notFound %}
|
|
21
|
+
{# Post not found — show message with external link #}
|
|
22
|
+
<div class="ap-post-detail__not-found">
|
|
23
|
+
<p>{{ __("activitypub.reader.post.notFound") }}</p>
|
|
24
|
+
{% if objectUrl %}
|
|
25
|
+
<p><a href="{{ objectUrl }}" target="_blank" rel="noopener noreferrer">{{ __("activitypub.reader.post.openExternal") }} →</a></p>
|
|
26
|
+
{% endif %}
|
|
27
|
+
</div>
|
|
28
|
+
{% else %}
|
|
29
|
+
{# Parent posts (thread context above main post) #}
|
|
30
|
+
{% if parentPosts and parentPosts.length > 0 %}
|
|
31
|
+
<div class="ap-post-detail__parents">
|
|
32
|
+
<h2 class="ap-post-detail__section-title">{{ __("activitypub.reader.post.parentPosts") }}</h2>
|
|
33
|
+
{% for parentItem in parentPosts %}
|
|
34
|
+
{% set item = parentItem %}
|
|
35
|
+
<div class="ap-post-detail__parent-item">
|
|
36
|
+
{% include "partials/ap-item-card.njk" %}
|
|
37
|
+
</div>
|
|
38
|
+
{% endfor %}
|
|
39
|
+
</div>
|
|
40
|
+
{% endif %}
|
|
41
|
+
|
|
42
|
+
{# Main post #}
|
|
43
|
+
<div class="ap-post-detail__main">
|
|
44
|
+
{% include "partials/ap-item-card.njk" %}
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
{# Replies (below main post) #}
|
|
48
|
+
{% if replyPosts and replyPosts.length > 0 %}
|
|
49
|
+
<div class="ap-post-detail__replies">
|
|
50
|
+
<h2 class="ap-post-detail__section-title">{{ __("activitypub.reader.post.replies") }}</h2>
|
|
51
|
+
{% for replyItem in replyPosts %}
|
|
52
|
+
{% set item = replyItem %}
|
|
53
|
+
<div class="ap-post-detail__reply-item">
|
|
54
|
+
{% include "partials/ap-item-card.njk" %}
|
|
55
|
+
</div>
|
|
56
|
+
{% endfor %}
|
|
57
|
+
</div>
|
|
58
|
+
{% endif %}
|
|
59
|
+
{% endif %}
|
|
60
|
+
</div>
|
|
61
|
+
{% endblock %}
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
{# Reader stylesheet — loaded in body is fine for modern browsers #}
|
|
8
8
|
<link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-activitypub/reader.css">
|
|
9
|
+
<link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-activitypub/reader-links.css">
|
|
10
|
+
|
|
11
|
+
{# AP link interception for internal navigation #}
|
|
12
|
+
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-links.js"></script>
|
|
9
13
|
|
|
10
14
|
{% block readercontent %}
|
|
11
15
|
{% endblock %}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
{# Reply context if this is a reply #}
|
|
18
18
|
{% if item.inReplyTo %}
|
|
19
19
|
<div class="ap-card__reply-to">
|
|
20
|
-
↩ {{ __("activitypub.reader.replyingTo") }} <a href="{{ item.inReplyTo }}">{{ item.inReplyTo }}</a>
|
|
20
|
+
↩ {{ __("activitypub.reader.replyingTo") }} <a href="{{ mountPath }}/admin/reader/post?url={{ item.inReplyTo | urlencode }}">{{ item.inReplyTo }}</a>
|
|
21
21
|
</div>
|
|
22
22
|
{% endif %}
|
|
23
23
|
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
{# Post title (articles only) #}
|
|
52
52
|
{% if item.name %}
|
|
53
53
|
<h2 class="ap-card__title">
|
|
54
|
-
<a href="{{ item.
|
|
54
|
+
<a href="{{ mountPath }}/admin/reader/post?url={{ item.uid | urlencode }}">{{ item.name }}</a>
|
|
55
55
|
</h2>
|
|
56
56
|
{% endif %}
|
|
57
57
|
|
|
@@ -72,6 +72,9 @@
|
|
|
72
72
|
</div>
|
|
73
73
|
{% endif %}
|
|
74
74
|
|
|
75
|
+
{# Link previews #}
|
|
76
|
+
{% include "partials/ap-link-preview.njk" %}
|
|
77
|
+
|
|
75
78
|
{# Media hidden behind CW #}
|
|
76
79
|
{% include "partials/ap-item-media.njk" %}
|
|
77
80
|
</div>
|
|
@@ -84,6 +87,9 @@
|
|
|
84
87
|
</div>
|
|
85
88
|
{% endif %}
|
|
86
89
|
|
|
90
|
+
{# Link previews #}
|
|
91
|
+
{% include "partials/ap-link-preview.njk" %}
|
|
92
|
+
|
|
87
93
|
{# Media visible directly #}
|
|
88
94
|
{% include "partials/ap-item-media.njk" %}
|
|
89
95
|
{% endif %}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{# Link preview cards for external links (OpenGraph) #}
|
|
2
|
+
{% if item.linkPreviews and item.linkPreviews.length > 0 %}
|
|
3
|
+
<div class="ap-link-previews">
|
|
4
|
+
{% for preview in item.linkPreviews %}
|
|
5
|
+
<a href="{{ preview.url }}"
|
|
6
|
+
rel="noopener"
|
|
7
|
+
target="_blank"
|
|
8
|
+
class="ap-link-preview"
|
|
9
|
+
aria-label="{{ __('activitypub.reader.linkPreview.label') }}: {{ preview.title }}">
|
|
10
|
+
|
|
11
|
+
<div class="ap-link-preview__text">
|
|
12
|
+
<p class="ap-link-preview__title">{{ preview.title }}</p>
|
|
13
|
+
|
|
14
|
+
{% if preview.description %}
|
|
15
|
+
<p class="ap-link-preview__desc">{{ preview.description }}</p>
|
|
16
|
+
{% endif %}
|
|
17
|
+
|
|
18
|
+
<p class="ap-link-preview__domain">
|
|
19
|
+
{% if preview.favicon %}
|
|
20
|
+
<img src="{{ preview.favicon }}" alt="" class="ap-link-preview__favicon" loading="lazy" />
|
|
21
|
+
{% endif %}
|
|
22
|
+
{{ preview.domain }}
|
|
23
|
+
</p>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
{% if preview.image %}
|
|
27
|
+
<div class="ap-link-preview__image">
|
|
28
|
+
<img src="{{ preview.image }}" alt="" loading="lazy" decoding="async" />
|
|
29
|
+
</div>
|
|
30
|
+
{% endif %}
|
|
31
|
+
</a>
|
|
32
|
+
{% endfor %}
|
|
33
|
+
</div>
|
|
34
|
+
{% endif %}
|