@rmdes/indiekit-endpoint-activitypub 2.4.4 → 2.5.0
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-infinite-scroll.js +48 -118
- package/assets/reader-tabs.js +10 -0
- package/assets/reader.css +9 -3
- package/lib/controllers/api-timeline.js +13 -119
- package/lib/controllers/explore.js +61 -117
- package/lib/controllers/hashtag-explore.js +7 -28
- package/lib/controllers/reader.js +11 -132
- package/lib/controllers/tag-timeline.js +8 -80
- package/lib/item-processing.js +226 -0
- package/package.json +1 -1
- package/views/activitypub-explore.njk +33 -13
- package/views/activitypub-reader.njk +8 -5
- package/views/activitypub-tag-timeline.njk +9 -5
|
@@ -1,121 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Infinite scroll — AlpineJS component for AJAX load-more
|
|
3
|
-
*
|
|
2
|
+
* Infinite scroll — unified AlpineJS component for AJAX load-more.
|
|
3
|
+
* Works for both reader timeline and explore view via data attributes.
|
|
4
|
+
*
|
|
5
|
+
* Required data attributes on the component element:
|
|
6
|
+
* data-cursor — initial pagination cursor value
|
|
7
|
+
* data-api-url — API endpoint URL (e.g., /activitypub/admin/reader/api/timeline)
|
|
8
|
+
* data-cursor-param — query param name for the cursor (e.g., "before" or "max_id")
|
|
9
|
+
* data-cursor-field — response JSON field for the next cursor (e.g., "before" or "maxId")
|
|
10
|
+
* data-timeline-id — DOM ID of the timeline container to append HTML into
|
|
11
|
+
*
|
|
12
|
+
* Optional:
|
|
13
|
+
* data-extra-params — JSON-encoded object of additional query params
|
|
14
|
+
* data-hide-pagination — CSS selector of no-JS pagination to hide
|
|
4
15
|
*/
|
|
5
16
|
|
|
6
17
|
document.addEventListener("alpine:init", () => {
|
|
7
18
|
// eslint-disable-next-line no-undef
|
|
8
|
-
Alpine.data("
|
|
19
|
+
Alpine.data("apInfiniteScroll", () => ({
|
|
9
20
|
loading: false,
|
|
10
21
|
done: false,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
22
|
+
cursor: null,
|
|
23
|
+
apiUrl: "",
|
|
24
|
+
cursorParam: "before",
|
|
25
|
+
cursorField: "before",
|
|
26
|
+
timelineId: "",
|
|
27
|
+
extraParams: {},
|
|
15
28
|
observer: null,
|
|
16
29
|
|
|
17
30
|
init() {
|
|
18
31
|
const el = this.$el;
|
|
19
|
-
this.
|
|
20
|
-
this.
|
|
21
|
-
this.
|
|
22
|
-
this.
|
|
23
|
-
|
|
24
|
-
if (!this.maxId) {
|
|
25
|
-
this.done = true;
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
this.observer = new IntersectionObserver(
|
|
30
|
-
(entries) => {
|
|
31
|
-
for (const entry of entries) {
|
|
32
|
-
if (entry.isIntersecting && !this.loading && !this.done) {
|
|
33
|
-
this.loadMore();
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
},
|
|
37
|
-
{ rootMargin: "200px" }
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
if (this.$refs.sentinel) {
|
|
41
|
-
this.observer.observe(this.$refs.sentinel);
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
|
-
|
|
45
|
-
async loadMore() {
|
|
46
|
-
if (this.loading || this.done || !this.maxId) return;
|
|
47
|
-
|
|
48
|
-
this.loading = true;
|
|
49
|
-
|
|
50
|
-
const timeline = document.getElementById("ap-explore-timeline");
|
|
51
|
-
const mountPath = timeline ? timeline.dataset.mountPath : "";
|
|
52
|
-
|
|
53
|
-
const params = new URLSearchParams({
|
|
54
|
-
instance: this.instance,
|
|
55
|
-
scope: this.scope,
|
|
56
|
-
max_id: this.maxId,
|
|
57
|
-
});
|
|
58
|
-
// Pass hashtag when in hashtag mode so infinite scroll stays on tag timeline
|
|
59
|
-
if (this.hashtag) {
|
|
60
|
-
params.set("hashtag", this.hashtag);
|
|
61
|
-
}
|
|
32
|
+
this.cursor = el.dataset.cursor || null;
|
|
33
|
+
this.apiUrl = el.dataset.apiUrl || "";
|
|
34
|
+
this.cursorParam = el.dataset.cursorParam || "before";
|
|
35
|
+
this.cursorField = el.dataset.cursorField || "before";
|
|
36
|
+
this.timelineId = el.dataset.timelineId || "";
|
|
62
37
|
|
|
38
|
+
// Parse extra params from JSON data attribute
|
|
63
39
|
try {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
70
|
-
|
|
71
|
-
const data = await res.json();
|
|
72
|
-
|
|
73
|
-
if (data.html && timeline) {
|
|
74
|
-
timeline.insertAdjacentHTML("beforeend", data.html);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (data.maxId) {
|
|
78
|
-
this.maxId = data.maxId;
|
|
79
|
-
} else {
|
|
80
|
-
this.done = true;
|
|
81
|
-
if (this.observer) this.observer.disconnect();
|
|
82
|
-
}
|
|
83
|
-
} catch (err) {
|
|
84
|
-
console.error("[ap-explore-scroll] load failed:", err.message);
|
|
85
|
-
} finally {
|
|
86
|
-
this.loading = false;
|
|
40
|
+
this.extraParams = JSON.parse(el.dataset.extraParams || "{}");
|
|
41
|
+
} catch {
|
|
42
|
+
this.extraParams = {};
|
|
87
43
|
}
|
|
88
|
-
},
|
|
89
|
-
|
|
90
|
-
destroy() {
|
|
91
|
-
if (this.observer) this.observer.disconnect();
|
|
92
|
-
},
|
|
93
|
-
}));
|
|
94
|
-
|
|
95
|
-
// eslint-disable-next-line no-undef
|
|
96
|
-
Alpine.data("apInfiniteScroll", () => ({
|
|
97
|
-
loading: false,
|
|
98
|
-
done: false,
|
|
99
|
-
before: null,
|
|
100
|
-
tab: "",
|
|
101
|
-
tag: "",
|
|
102
|
-
observer: null,
|
|
103
|
-
|
|
104
|
-
init() {
|
|
105
|
-
const el = this.$el;
|
|
106
|
-
this.before = el.dataset.before || null;
|
|
107
|
-
this.tab = el.dataset.tab || "";
|
|
108
|
-
this.tag = el.dataset.tag || "";
|
|
109
44
|
|
|
110
45
|
// Hide the no-JS pagination fallback now that JS is active
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
document.getElementById(
|
|
114
|
-
|
|
115
|
-
paginationEl.style.display = "none";
|
|
46
|
+
const hideSel = el.dataset.hidePagination;
|
|
47
|
+
if (hideSel) {
|
|
48
|
+
const paginationEl = document.getElementById(hideSel);
|
|
49
|
+
if (paginationEl) paginationEl.style.display = "none";
|
|
116
50
|
}
|
|
117
51
|
|
|
118
|
-
if (!this.
|
|
52
|
+
if (!this.cursor) {
|
|
119
53
|
this.done = true;
|
|
120
54
|
return;
|
|
121
55
|
}
|
|
@@ -129,7 +63,7 @@ document.addEventListener("alpine:init", () => {
|
|
|
129
63
|
}
|
|
130
64
|
}
|
|
131
65
|
},
|
|
132
|
-
{ rootMargin: "200px" }
|
|
66
|
+
{ rootMargin: "200px" },
|
|
133
67
|
);
|
|
134
68
|
|
|
135
69
|
if (this.$refs.sentinel) {
|
|
@@ -138,36 +72,36 @@ document.addEventListener("alpine:init", () => {
|
|
|
138
72
|
},
|
|
139
73
|
|
|
140
74
|
async loadMore() {
|
|
141
|
-
if (this.loading || this.done || !this.
|
|
75
|
+
if (this.loading || this.done || !this.cursor) return;
|
|
142
76
|
|
|
143
77
|
this.loading = true;
|
|
144
78
|
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (this.tab) params.set("tab", this.tab);
|
|
150
|
-
if (this.tag) params.set("tag", this.tag);
|
|
79
|
+
const params = new URLSearchParams({
|
|
80
|
+
[this.cursorParam]: this.cursor,
|
|
81
|
+
...this.extraParams,
|
|
82
|
+
});
|
|
151
83
|
|
|
152
84
|
try {
|
|
153
85
|
const res = await fetch(
|
|
154
|
-
`${
|
|
155
|
-
{ headers: { Accept: "application/json" } }
|
|
86
|
+
`${this.apiUrl}?${params.toString()}`,
|
|
87
|
+
{ headers: { Accept: "application/json" } },
|
|
156
88
|
);
|
|
157
89
|
|
|
158
90
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
159
91
|
|
|
160
92
|
const data = await res.json();
|
|
161
93
|
|
|
94
|
+
const timeline = this.timelineId
|
|
95
|
+
? document.getElementById(this.timelineId)
|
|
96
|
+
: null;
|
|
97
|
+
|
|
162
98
|
if (data.html && timeline) {
|
|
163
|
-
// Append the returned pre-rendered HTML
|
|
164
99
|
timeline.insertAdjacentHTML("beforeend", data.html);
|
|
165
100
|
}
|
|
166
101
|
|
|
167
|
-
if (data.
|
|
168
|
-
this.
|
|
102
|
+
if (data[this.cursorField]) {
|
|
103
|
+
this.cursor = data[this.cursorField];
|
|
169
104
|
} else {
|
|
170
|
-
// No more items
|
|
171
105
|
this.done = true;
|
|
172
106
|
if (this.observer) this.observer.disconnect();
|
|
173
107
|
}
|
|
@@ -178,10 +112,6 @@ document.addEventListener("alpine:init", () => {
|
|
|
178
112
|
}
|
|
179
113
|
},
|
|
180
114
|
|
|
181
|
-
appendItems(/* detail */) {
|
|
182
|
-
// Custom event hook — not used in this implementation but kept for extensibility
|
|
183
|
-
},
|
|
184
|
-
|
|
185
115
|
destroy() {
|
|
186
116
|
if (this.observer) this.observer.disconnect();
|
|
187
117
|
},
|
package/assets/reader-tabs.js
CHANGED
|
@@ -366,6 +366,16 @@ document.addEventListener("alpine:init", () => {
|
|
|
366
366
|
}
|
|
367
367
|
},
|
|
368
368
|
|
|
369
|
+
// ── Public load-more method (called by button click) ────────────────────
|
|
370
|
+
|
|
371
|
+
loadMoreTab(tab) {
|
|
372
|
+
if (tab.type === "instance") {
|
|
373
|
+
this._loadMoreInstanceTab(tab);
|
|
374
|
+
} else if (tab.type === "hashtag") {
|
|
375
|
+
this._loadMoreHashtagTab(tab);
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
|
|
369
379
|
// ── Infinite scroll for tab panels ───────────────────────────────────────
|
|
370
380
|
|
|
371
381
|
_setupScrollObserver(tab) {
|
package/assets/reader.css
CHANGED
|
@@ -2486,15 +2486,21 @@
|
|
|
2486
2486
|
}
|
|
2487
2487
|
|
|
2488
2488
|
.ap-quote-embed__content {
|
|
2489
|
-
-webkit-box-orient: vertical;
|
|
2490
|
-
-webkit-line-clamp: 6;
|
|
2491
2489
|
color: var(--color-on-background);
|
|
2492
|
-
display: -webkit-box;
|
|
2493
2490
|
font-size: var(--font-size-s);
|
|
2494
2491
|
line-height: 1.5;
|
|
2492
|
+
max-height: calc(1.5em * 6);
|
|
2495
2493
|
overflow: hidden;
|
|
2496
2494
|
}
|
|
2497
2495
|
|
|
2496
|
+
.ap-quote-embed__content a {
|
|
2497
|
+
display: inline;
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
.ap-quote-embed__content a span {
|
|
2501
|
+
display: inline;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2498
2504
|
.ap-quote-embed__content p {
|
|
2499
2505
|
margin: 0 0 var(--space-xs);
|
|
2500
2506
|
}
|
|
@@ -4,13 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { getTimelineItems, countNewItems, markItemsRead } from "../storage/timeline.js";
|
|
6
6
|
import { getToken, validateToken } from "../csrf.js";
|
|
7
|
-
import {
|
|
8
|
-
getMutedUrls,
|
|
9
|
-
getMutedKeywords,
|
|
10
|
-
getBlockedUrls,
|
|
11
|
-
getFilterMode,
|
|
12
|
-
} from "../storage/moderation.js";
|
|
13
|
-
import { stripQuoteReferenceHtml } from "../og-unfurl.js";
|
|
7
|
+
import { postProcessItems, applyTabFilter, loadModerationData, renderItemCards } from "../item-processing.js";
|
|
14
8
|
|
|
15
9
|
export function apiTimelineController(mountPath) {
|
|
16
10
|
return async (request, response, next) => {
|
|
@@ -26,7 +20,7 @@ export function apiTimelineController(mountPath) {
|
|
|
26
20
|
const before = request.query.before;
|
|
27
21
|
const limit = 20;
|
|
28
22
|
|
|
29
|
-
// Build storage query options
|
|
23
|
+
// Build storage query options
|
|
30
24
|
const unread = request.query.unread === "1";
|
|
31
25
|
const options = { before, limit, unread };
|
|
32
26
|
|
|
@@ -45,132 +39,32 @@ export function apiTimelineController(mountPath) {
|
|
|
45
39
|
|
|
46
40
|
const result = await getTimelineItems(collections, options);
|
|
47
41
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
if (!tag) {
|
|
51
|
-
if (tab === "replies") {
|
|
52
|
-
items = items.filter((item) => item.inReplyTo);
|
|
53
|
-
} else if (tab === "media") {
|
|
54
|
-
items = items.filter(
|
|
55
|
-
(item) =>
|
|
56
|
-
(item.photo && item.photo.length > 0) ||
|
|
57
|
-
(item.video && item.video.length > 0) ||
|
|
58
|
-
(item.audio && item.audio.length > 0)
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
42
|
+
// Tab filtering for types not supported by storage layer
|
|
43
|
+
const tabFiltered = tag ? result.items : applyTabFilter(result.items, tab);
|
|
62
44
|
|
|
63
|
-
//
|
|
45
|
+
// Shared processing pipeline: moderation, quote stripping, interactions
|
|
64
46
|
const modCollections = {
|
|
65
47
|
ap_muted: application?.collections?.get("ap_muted"),
|
|
66
48
|
ap_blocked: application?.collections?.get("ap_blocked"),
|
|
67
49
|
ap_profile: application?.collections?.get("ap_profile"),
|
|
68
50
|
};
|
|
69
|
-
const
|
|
70
|
-
await Promise.all([
|
|
71
|
-
getMutedUrls(modCollections),
|
|
72
|
-
getMutedKeywords(modCollections),
|
|
73
|
-
getBlockedUrls(modCollections),
|
|
74
|
-
getFilterMode(modCollections),
|
|
75
|
-
]);
|
|
76
|
-
const blockedSet = new Set(blockedUrls);
|
|
77
|
-
const mutedSet = new Set(mutedUrls);
|
|
78
|
-
|
|
79
|
-
if (blockedSet.size > 0 || mutedSet.size > 0 || mutedKeywords.length > 0) {
|
|
80
|
-
items = items.filter((item) => {
|
|
81
|
-
if (item.author?.url && blockedSet.has(item.author.url)) {
|
|
82
|
-
return false;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const isMutedActor = item.author?.url && mutedSet.has(item.author.url);
|
|
86
|
-
let matchedKeyword = null;
|
|
87
|
-
if (mutedKeywords.length > 0) {
|
|
88
|
-
const searchable = [item.content?.text, item.name, item.summary]
|
|
89
|
-
.filter(Boolean)
|
|
90
|
-
.join(" ")
|
|
91
|
-
.toLowerCase();
|
|
92
|
-
if (searchable) {
|
|
93
|
-
matchedKeyword = mutedKeywords.find((kw) =>
|
|
94
|
-
searchable.includes(kw.toLowerCase())
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (isMutedActor || matchedKeyword) {
|
|
100
|
-
if (filterMode === "warn") {
|
|
101
|
-
item._moderated = true;
|
|
102
|
-
item._moderationReason = isMutedActor ? "muted_account" : "muted_keyword";
|
|
103
|
-
if (matchedKeyword) item._moderationKeyword = matchedKeyword;
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return true;
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Get interaction state
|
|
114
|
-
const interactionsCol = application?.collections?.get("ap_interactions");
|
|
115
|
-
const interactionMap = {};
|
|
116
|
-
|
|
117
|
-
if (interactionsCol) {
|
|
118
|
-
const lookupUrls = new Set();
|
|
119
|
-
const objectUrlToUid = new Map();
|
|
120
|
-
for (const item of items) {
|
|
121
|
-
const uid = item.uid;
|
|
122
|
-
const displayUrl = item.url || item.originalUrl;
|
|
123
|
-
if (uid) { lookupUrls.add(uid); objectUrlToUid.set(uid, uid); }
|
|
124
|
-
if (displayUrl) { lookupUrls.add(displayUrl); objectUrlToUid.set(displayUrl, uid || displayUrl); }
|
|
125
|
-
}
|
|
126
|
-
if (lookupUrls.size > 0) {
|
|
127
|
-
const interactions = await interactionsCol
|
|
128
|
-
.find({ objectUrl: { $in: [...lookupUrls] } })
|
|
129
|
-
.toArray();
|
|
130
|
-
for (const interaction of interactions) {
|
|
131
|
-
const key = objectUrlToUid.get(interaction.objectUrl) || interaction.objectUrl;
|
|
132
|
-
if (!interactionMap[key]) interactionMap[key] = {};
|
|
133
|
-
interactionMap[key][interaction.type] = true;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
51
|
+
const moderation = await loadModerationData(modCollections);
|
|
137
52
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
53
|
+
const { items, interactionMap } = await postProcessItems(tabFiltered, {
|
|
54
|
+
moderation,
|
|
55
|
+
interactionsCol: application?.collections?.get("ap_interactions"),
|
|
56
|
+
});
|
|
145
57
|
|
|
146
58
|
const csrfToken = getToken(request.session);
|
|
147
|
-
|
|
148
|
-
// Render each card server-side using the same Nunjucks template
|
|
149
|
-
// Merge response.locals so that i18n (__), mountPath, etc. are available
|
|
150
|
-
const templateData = {
|
|
59
|
+
const html = await renderItemCards(items, request, {
|
|
151
60
|
...response.locals,
|
|
152
61
|
mountPath,
|
|
153
62
|
csrfToken,
|
|
154
63
|
interactionMap,
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
const htmlParts = await Promise.all(
|
|
158
|
-
items.map((item) => {
|
|
159
|
-
return new Promise((resolve, reject) => {
|
|
160
|
-
request.app.render(
|
|
161
|
-
"partials/ap-item-card.njk",
|
|
162
|
-
{ ...templateData, item },
|
|
163
|
-
(err, html) => {
|
|
164
|
-
if (err) reject(err);
|
|
165
|
-
else resolve(html);
|
|
166
|
-
}
|
|
167
|
-
);
|
|
168
|
-
});
|
|
169
|
-
})
|
|
170
|
-
);
|
|
64
|
+
});
|
|
171
65
|
|
|
172
66
|
response.json({
|
|
173
|
-
html
|
|
67
|
+
html,
|
|
174
68
|
before: result.before,
|
|
175
69
|
});
|
|
176
70
|
} catch (error) {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { searchInstances, checkInstanceTimeline, getPopularAccounts } from "../fedidb.js";
|
|
9
9
|
import { getToken } from "../csrf.js";
|
|
10
10
|
import { validateInstance, validateHashtag, mapMastodonStatusToItem } from "./explore-utils.js";
|
|
11
|
-
import {
|
|
11
|
+
import { postProcessItems, renderItemCards } from "../item-processing.js";
|
|
12
12
|
|
|
13
13
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
14
14
|
const MAX_RESULTS = 20;
|
|
@@ -16,6 +16,57 @@ const MAX_RESULTS = 20;
|
|
|
16
16
|
// Re-export validateInstance for backward compatibility (used by tabs.js, index.js)
|
|
17
17
|
export { validateInstance } from "./explore-utils.js";
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Fetch statuses from a remote Mastodon-compatible instance.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} instance - Validated hostname
|
|
23
|
+
* @param {object} options
|
|
24
|
+
* @param {string} [options.scope] - "local" or "federated"
|
|
25
|
+
* @param {string} [options.hashtag] - Validated hashtag (no #)
|
|
26
|
+
* @param {string} [options.maxId] - Pagination cursor
|
|
27
|
+
* @param {number} [options.limit] - Max results
|
|
28
|
+
* @returns {Promise<{ items: Array, nextMaxId: string|null }>}
|
|
29
|
+
*/
|
|
30
|
+
export async function fetchMastodonTimeline(instance, { scope = "local", hashtag, maxId, limit = MAX_RESULTS } = {}) {
|
|
31
|
+
const isLocal = scope === "local";
|
|
32
|
+
let apiUrl;
|
|
33
|
+
if (hashtag) {
|
|
34
|
+
apiUrl = new URL(`https://${instance}/api/v1/timelines/tag/${encodeURIComponent(hashtag)}`);
|
|
35
|
+
} else {
|
|
36
|
+
apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
|
|
37
|
+
}
|
|
38
|
+
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
|
|
39
|
+
apiUrl.searchParams.set("limit", String(limit));
|
|
40
|
+
if (maxId) apiUrl.searchParams.set("max_id", maxId);
|
|
41
|
+
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
44
|
+
|
|
45
|
+
const fetchRes = await fetch(apiUrl.toString(), {
|
|
46
|
+
headers: { Accept: "application/json" },
|
|
47
|
+
signal: controller.signal,
|
|
48
|
+
});
|
|
49
|
+
clearTimeout(timeoutId);
|
|
50
|
+
|
|
51
|
+
if (!fetchRes.ok) {
|
|
52
|
+
throw new Error(`Remote instance returned HTTP ${fetchRes.status}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const statuses = await fetchRes.json();
|
|
56
|
+
if (!Array.isArray(statuses)) {
|
|
57
|
+
throw new Error("Unexpected API response format");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const items = statuses.map((s) => mapMastodonStatusToItem(s, instance));
|
|
61
|
+
|
|
62
|
+
const nextMaxId =
|
|
63
|
+
statuses.length === limit && statuses.length > 0
|
|
64
|
+
? statuses[statuses.length - 1].id || null
|
|
65
|
+
: null;
|
|
66
|
+
|
|
67
|
+
return { items, nextMaxId };
|
|
68
|
+
}
|
|
69
|
+
|
|
19
70
|
export function exploreController(mountPath) {
|
|
20
71
|
return async (request, response, next) => {
|
|
21
72
|
try {
|
|
@@ -60,58 +111,15 @@ export function exploreController(mountPath) {
|
|
|
60
111
|
});
|
|
61
112
|
}
|
|
62
113
|
|
|
63
|
-
// Build API URL: hashtag timeline or public timeline
|
|
64
|
-
const isLocal = scope === "local";
|
|
65
|
-
let apiUrl;
|
|
66
|
-
if (hashtag) {
|
|
67
|
-
apiUrl = new URL(`https://${instance}/api/v1/timelines/tag/${encodeURIComponent(hashtag)}`);
|
|
68
|
-
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
|
|
69
|
-
} else {
|
|
70
|
-
apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
|
|
71
|
-
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
|
|
72
|
-
}
|
|
73
|
-
apiUrl.searchParams.set("limit", String(MAX_RESULTS));
|
|
74
|
-
if (maxId) apiUrl.searchParams.set("max_id", maxId);
|
|
75
|
-
|
|
76
114
|
let items = [];
|
|
77
115
|
let nextMaxId = null;
|
|
78
116
|
let error = null;
|
|
79
117
|
|
|
80
118
|
try {
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
headers: { Accept: "application/json" },
|
|
86
|
-
signal: controller.signal,
|
|
87
|
-
});
|
|
88
|
-
clearTimeout(timeoutId);
|
|
89
|
-
|
|
90
|
-
if (!fetchRes.ok) {
|
|
91
|
-
throw new Error(`Remote instance returned HTTP ${fetchRes.status}`);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const statuses = await fetchRes.json();
|
|
95
|
-
|
|
96
|
-
if (!Array.isArray(statuses)) {
|
|
97
|
-
throw new Error("Unexpected API response format");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
items = statuses.map((s) => mapMastodonStatusToItem(s, instance));
|
|
101
|
-
|
|
102
|
-
// Strip "RE:" paragraphs from items that have quote embeds
|
|
103
|
-
for (const item of items) {
|
|
104
|
-
const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
|
|
105
|
-
if (item.quote && quoteRef && item.content?.html) {
|
|
106
|
-
item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Get next max_id from last item for pagination
|
|
111
|
-
if (statuses.length === MAX_RESULTS && statuses.length > 0) {
|
|
112
|
-
const last = statuses[statuses.length - 1];
|
|
113
|
-
nextMaxId = last.id || null;
|
|
114
|
-
}
|
|
119
|
+
const result = await fetchMastodonTimeline(instance, { scope, hashtag, maxId });
|
|
120
|
+
const processed = await postProcessItems(result.items);
|
|
121
|
+
items = processed.items;
|
|
122
|
+
nextMaxId = result.nextMaxId;
|
|
115
123
|
} catch (fetchError) {
|
|
116
124
|
const msg = fetchError.name === "AbortError"
|
|
117
125
|
? response.locals.__("activitypub.reader.explore.timeout")
|
|
@@ -130,7 +138,6 @@ export function exploreController(mountPath) {
|
|
|
130
138
|
error,
|
|
131
139
|
mountPath,
|
|
132
140
|
csrfToken,
|
|
133
|
-
// Pass empty interactionMap — explore posts are not in our DB
|
|
134
141
|
interactionMap: {},
|
|
135
142
|
});
|
|
136
143
|
} catch (error) {
|
|
@@ -157,81 +164,18 @@ export function exploreApiController(mountPath) {
|
|
|
157
164
|
return response.status(400).json({ error: "Invalid instance" });
|
|
158
165
|
}
|
|
159
166
|
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
let apiUrl;
|
|
163
|
-
if (hashtag) {
|
|
164
|
-
apiUrl = new URL(`https://${instance}/api/v1/timelines/tag/${encodeURIComponent(hashtag)}`);
|
|
165
|
-
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
|
|
166
|
-
} else {
|
|
167
|
-
apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
|
|
168
|
-
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
|
|
169
|
-
}
|
|
170
|
-
apiUrl.searchParams.set("limit", String(MAX_RESULTS));
|
|
171
|
-
if (maxId) apiUrl.searchParams.set("max_id", maxId);
|
|
172
|
-
|
|
173
|
-
const controller = new AbortController();
|
|
174
|
-
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
167
|
+
const { items: rawItems, nextMaxId } = await fetchMastodonTimeline(instance, { scope, hashtag, maxId });
|
|
168
|
+
const { items } = await postProcessItems(rawItems);
|
|
175
169
|
|
|
176
|
-
const fetchRes = await fetch(apiUrl.toString(), {
|
|
177
|
-
headers: { Accept: "application/json" },
|
|
178
|
-
signal: controller.signal,
|
|
179
|
-
});
|
|
180
|
-
clearTimeout(timeoutId);
|
|
181
|
-
|
|
182
|
-
if (!fetchRes.ok) {
|
|
183
|
-
return response.status(502).json({ error: `Remote returned ${fetchRes.status}` });
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const statuses = await fetchRes.json();
|
|
187
|
-
if (!Array.isArray(statuses)) {
|
|
188
|
-
return response.status(502).json({ error: "Unexpected API response" });
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const items = statuses.map((s) => mapMastodonStatusToItem(s, instance));
|
|
192
|
-
|
|
193
|
-
// Strip "RE:" paragraphs from items that have quote embeds
|
|
194
|
-
for (const item of items) {
|
|
195
|
-
const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
|
|
196
|
-
if (item.quote && quoteRef && item.content?.html) {
|
|
197
|
-
item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
let nextMaxId = null;
|
|
202
|
-
if (statuses.length === MAX_RESULTS && statuses.length > 0) {
|
|
203
|
-
const last = statuses[statuses.length - 1];
|
|
204
|
-
nextMaxId = last.id || null;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Render each card server-side
|
|
208
170
|
const csrfToken = getToken(request.session);
|
|
209
|
-
const
|
|
171
|
+
const html = await renderItemCards(items, request, {
|
|
210
172
|
...response.locals,
|
|
211
173
|
mountPath,
|
|
212
174
|
csrfToken,
|
|
213
175
|
interactionMap: {},
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
const htmlParts = await Promise.all(
|
|
217
|
-
items.map((item) => {
|
|
218
|
-
return new Promise((resolve, reject) => {
|
|
219
|
-
request.app.render(
|
|
220
|
-
"partials/ap-item-card.njk",
|
|
221
|
-
{ ...templateData, item },
|
|
222
|
-
(err, html) => {
|
|
223
|
-
if (err) reject(err);
|
|
224
|
-
else resolve(html);
|
|
225
|
-
}
|
|
226
|
-
);
|
|
227
|
-
});
|
|
228
|
-
})
|
|
229
|
-
);
|
|
230
|
-
|
|
231
|
-
response.json({
|
|
232
|
-
html: htmlParts.join(""),
|
|
233
|
-
maxId: nextMaxId,
|
|
234
176
|
});
|
|
177
|
+
|
|
178
|
+
response.json({ html, maxId: nextMaxId });
|
|
235
179
|
} catch (error) {
|
|
236
180
|
next(error);
|
|
237
181
|
}
|