@rmdes/indiekit-endpoint-activitypub 2.4.5 → 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/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 +7 -5
- 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
|
},
|
|
@@ -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
|
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { validateHashtag, mapMastodonStatusToItem } from "./explore-utils.js";
|
|
20
20
|
import { getToken } from "../csrf.js";
|
|
21
|
-
import {
|
|
21
|
+
import { postProcessItems, renderItemCards } from "../item-processing.js";
|
|
22
22
|
|
|
23
23
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
24
24
|
const PAGE_SIZE = 20;
|
|
@@ -180,47 +180,26 @@ export function hashtagExploreApiController(mountPath) {
|
|
|
180
180
|
const pageItems = dedupedItems.slice(0, PAGE_SIZE);
|
|
181
181
|
|
|
182
182
|
// Map to timeline item format
|
|
183
|
-
const
|
|
183
|
+
const rawItems = pageItems.map(({ status, domain }) =>
|
|
184
184
|
mapMastodonStatusToItem(status, domain)
|
|
185
185
|
);
|
|
186
186
|
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
|
|
190
|
-
if (item.quote && quoteRef && item.content?.html) {
|
|
191
|
-
item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
187
|
+
// Shared processing pipeline (quote stripping, etc.)
|
|
188
|
+
const { items } = await postProcessItems(rawItems);
|
|
194
189
|
|
|
195
190
|
// Render HTML AFTER merge/dedup/paginate (don't waste CPU on discarded items)
|
|
196
191
|
const csrfToken = getToken(request.session);
|
|
197
|
-
const
|
|
192
|
+
const html = await renderItemCards(items, request, {
|
|
198
193
|
...response.locals,
|
|
199
194
|
mountPath,
|
|
200
195
|
csrfToken,
|
|
201
196
|
interactionMap: {},
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
const htmlParts = await Promise.all(
|
|
205
|
-
items.map(
|
|
206
|
-
(item) =>
|
|
207
|
-
new Promise((resolve, reject) => {
|
|
208
|
-
request.app.render(
|
|
209
|
-
"partials/ap-item-card.njk",
|
|
210
|
-
{ ...templateData, item },
|
|
211
|
-
(err, html) => {
|
|
212
|
-
if (err) reject(err);
|
|
213
|
-
else resolve(html);
|
|
214
|
-
}
|
|
215
|
-
);
|
|
216
|
-
})
|
|
217
|
-
)
|
|
218
|
-
);
|
|
197
|
+
});
|
|
219
198
|
|
|
220
199
|
const instanceLabels = instanceTabs.map((t) => t.domain);
|
|
221
200
|
|
|
222
201
|
response.json({
|
|
223
|
-
html
|
|
202
|
+
html,
|
|
224
203
|
cursors: updatedCursors,
|
|
225
204
|
sources,
|
|
226
205
|
instancesQueried: instanceTabs.length,
|
|
@@ -12,14 +12,8 @@ import {
|
|
|
12
12
|
deleteNotification,
|
|
13
13
|
} from "../storage/notifications.js";
|
|
14
14
|
import { getToken, validateToken } from "../csrf.js";
|
|
15
|
-
import {
|
|
16
|
-
getMutedUrls,
|
|
17
|
-
getMutedKeywords,
|
|
18
|
-
getBlockedUrls,
|
|
19
|
-
getFilterMode,
|
|
20
|
-
} from "../storage/moderation.js";
|
|
21
15
|
import { getFollowedTags } from "../storage/followed-tags.js";
|
|
22
|
-
import {
|
|
16
|
+
import { postProcessItems, applyTabFilter, loadModerationData } from "../item-processing.js";
|
|
23
17
|
|
|
24
18
|
// Re-export controllers from split modules for backward compatibility
|
|
25
19
|
export {
|
|
@@ -55,7 +49,7 @@ export function readerController(mountPath) {
|
|
|
55
49
|
// Build query options
|
|
56
50
|
const options = { before, after, limit, unread };
|
|
57
51
|
|
|
58
|
-
// Tab filtering
|
|
52
|
+
// Tab filtering at storage level
|
|
59
53
|
if (tab === "notes") {
|
|
60
54
|
options.type = "note";
|
|
61
55
|
options.excludeReplies = true;
|
|
@@ -68,82 +62,21 @@ export function readerController(mountPath) {
|
|
|
68
62
|
// Get timeline items
|
|
69
63
|
const result = await getTimelineItems(collections, options);
|
|
70
64
|
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
if (tab === "replies") {
|
|
74
|
-
items = items.filter((item) => item.inReplyTo);
|
|
75
|
-
} else if (tab === "media") {
|
|
76
|
-
items = items.filter(
|
|
77
|
-
(item) =>
|
|
78
|
-
(item.photo && item.photo.length > 0) ||
|
|
79
|
-
(item.video && item.video.length > 0) ||
|
|
80
|
-
(item.audio && item.audio.length > 0),
|
|
81
|
-
);
|
|
82
|
-
}
|
|
65
|
+
// Tab filtering for types not supported by storage layer
|
|
66
|
+
const tabFiltered = applyTabFilter(result.items, tab);
|
|
83
67
|
|
|
84
|
-
//
|
|
68
|
+
// Load moderation data + interactions, apply shared pipeline
|
|
85
69
|
const modCollections = {
|
|
86
70
|
ap_muted: application?.collections?.get("ap_muted"),
|
|
87
71
|
ap_blocked: application?.collections?.get("ap_blocked"),
|
|
88
72
|
ap_profile: application?.collections?.get("ap_profile"),
|
|
89
73
|
};
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
]);
|
|
97
|
-
const blockedSet = new Set(blockedUrls);
|
|
98
|
-
const mutedSet = new Set(mutedUrls);
|
|
99
|
-
|
|
100
|
-
if (blockedSet.size > 0 || mutedSet.size > 0 || mutedKeywords.length > 0) {
|
|
101
|
-
items = items.filter((item) => {
|
|
102
|
-
// Blocked actors are ALWAYS hidden
|
|
103
|
-
if (item.author?.url && blockedSet.has(item.author.url)) {
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Check muted actor
|
|
108
|
-
const isMutedActor =
|
|
109
|
-
item.author?.url && mutedSet.has(item.author.url);
|
|
110
|
-
|
|
111
|
-
// Check muted keywords against content, title, and summary
|
|
112
|
-
let matchedKeyword = null;
|
|
113
|
-
if (mutedKeywords.length > 0) {
|
|
114
|
-
const searchable = [
|
|
115
|
-
item.content?.text,
|
|
116
|
-
item.name,
|
|
117
|
-
item.summary,
|
|
118
|
-
]
|
|
119
|
-
.filter(Boolean)
|
|
120
|
-
.join(" ")
|
|
121
|
-
.toLowerCase();
|
|
122
|
-
if (searchable) {
|
|
123
|
-
matchedKeyword = mutedKeywords.find((kw) =>
|
|
124
|
-
searchable.includes(kw.toLowerCase()),
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (isMutedActor || matchedKeyword) {
|
|
130
|
-
if (filterMode === "warn") {
|
|
131
|
-
// Mark for content warning instead of hiding
|
|
132
|
-
item._moderated = true;
|
|
133
|
-
item._moderationReason = isMutedActor
|
|
134
|
-
? "muted_account"
|
|
135
|
-
: "muted_keyword";
|
|
136
|
-
if (matchedKeyword) {
|
|
137
|
-
item._moderationKeyword = matchedKeyword;
|
|
138
|
-
}
|
|
139
|
-
return true;
|
|
140
|
-
}
|
|
141
|
-
return false;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return true;
|
|
145
|
-
});
|
|
146
|
-
}
|
|
74
|
+
const moderation = await loadModerationData(modCollections);
|
|
75
|
+
|
|
76
|
+
const { items, interactionMap } = await postProcessItems(tabFiltered, {
|
|
77
|
+
moderation,
|
|
78
|
+
interactionsCol: application?.collections?.get("ap_interactions"),
|
|
79
|
+
});
|
|
147
80
|
|
|
148
81
|
// Get unread notification count for badge + unread timeline count for toggle
|
|
149
82
|
const [unreadCount, unreadTimelineCount] = await Promise.all([
|
|
@@ -151,60 +84,6 @@ export function readerController(mountPath) {
|
|
|
151
84
|
countUnreadItems(collections),
|
|
152
85
|
]);
|
|
153
86
|
|
|
154
|
-
// Get interaction state for liked/boosted indicators
|
|
155
|
-
// Interactions are keyed by canonical AP uid (new) or display url (legacy).
|
|
156
|
-
// Query by both, normalize map keys to uid for template lookup.
|
|
157
|
-
const interactionsCol =
|
|
158
|
-
application?.collections?.get("ap_interactions");
|
|
159
|
-
const interactionMap = {};
|
|
160
|
-
|
|
161
|
-
if (interactionsCol) {
|
|
162
|
-
const lookupUrls = new Set();
|
|
163
|
-
const objectUrlToUid = new Map();
|
|
164
|
-
|
|
165
|
-
for (const item of items) {
|
|
166
|
-
const uid = item.uid;
|
|
167
|
-
const displayUrl = item.url || item.originalUrl;
|
|
168
|
-
|
|
169
|
-
if (uid) {
|
|
170
|
-
lookupUrls.add(uid);
|
|
171
|
-
objectUrlToUid.set(uid, uid);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (displayUrl) {
|
|
175
|
-
lookupUrls.add(displayUrl);
|
|
176
|
-
objectUrlToUid.set(displayUrl, uid || displayUrl);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (lookupUrls.size > 0) {
|
|
181
|
-
const interactions = await interactionsCol
|
|
182
|
-
.find({ objectUrl: { $in: [...lookupUrls] } })
|
|
183
|
-
.toArray();
|
|
184
|
-
|
|
185
|
-
for (const interaction of interactions) {
|
|
186
|
-
// Normalize to uid so template can look up by itemUid
|
|
187
|
-
const key =
|
|
188
|
-
objectUrlToUid.get(interaction.objectUrl) ||
|
|
189
|
-
interaction.objectUrl;
|
|
190
|
-
|
|
191
|
-
if (!interactionMap[key]) {
|
|
192
|
-
interactionMap[key] = {};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
interactionMap[key][interaction.type] = true;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Strip "RE:" paragraphs from items that have quote embeds
|
|
201
|
-
for (const item of items) {
|
|
202
|
-
const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
|
|
203
|
-
if (item.quote && quoteRef && item.content?.html) {
|
|
204
|
-
item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
87
|
// CSRF token for interaction forms
|
|
209
88
|
const csrfToken = getToken(request.session);
|
|
210
89
|
|
|
@@ -4,12 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { getTimelineItems } from "../storage/timeline.js";
|
|
6
6
|
import { getToken } from "../csrf.js";
|
|
7
|
-
import {
|
|
8
|
-
getMutedUrls,
|
|
9
|
-
getMutedKeywords,
|
|
10
|
-
getBlockedUrls,
|
|
11
|
-
getFilterMode,
|
|
12
|
-
} from "../storage/moderation.js";
|
|
7
|
+
import { postProcessItems, loadModerationData } from "../item-processing.js";
|
|
13
8
|
|
|
14
9
|
export function tagTimelineController(mountPath) {
|
|
15
10
|
return async (request, response, next) => {
|
|
@@ -36,88 +31,21 @@ export function tagTimelineController(mountPath) {
|
|
|
36
31
|
|
|
37
32
|
// Get timeline items filtered by tag
|
|
38
33
|
const result = await getTimelineItems(collections, { before, after, limit, tag });
|
|
39
|
-
let items = result.items;
|
|
40
34
|
|
|
41
|
-
//
|
|
35
|
+
// Shared processing pipeline: moderation, quote stripping, interactions
|
|
42
36
|
const modCollections = {
|
|
43
37
|
ap_muted: application?.collections?.get("ap_muted"),
|
|
44
38
|
ap_blocked: application?.collections?.get("ap_blocked"),
|
|
45
39
|
ap_profile: application?.collections?.get("ap_profile"),
|
|
46
40
|
};
|
|
47
|
-
const
|
|
48
|
-
await Promise.all([
|
|
49
|
-
getMutedUrls(modCollections),
|
|
50
|
-
getMutedKeywords(modCollections),
|
|
51
|
-
getBlockedUrls(modCollections),
|
|
52
|
-
getFilterMode(modCollections),
|
|
53
|
-
]);
|
|
54
|
-
const blockedSet = new Set(blockedUrls);
|
|
55
|
-
const mutedSet = new Set(mutedUrls);
|
|
41
|
+
const moderation = await loadModerationData(modCollections);
|
|
56
42
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const isMutedActor = item.author?.url && mutedSet.has(item.author.url);
|
|
64
|
-
|
|
65
|
-
let matchedKeyword = null;
|
|
66
|
-
if (mutedKeywords.length > 0) {
|
|
67
|
-
const searchable = [item.content?.text, item.name, item.summary]
|
|
68
|
-
.filter(Boolean)
|
|
69
|
-
.join(" ")
|
|
70
|
-
.toLowerCase();
|
|
71
|
-
if (searchable) {
|
|
72
|
-
matchedKeyword = mutedKeywords.find((kw) =>
|
|
73
|
-
searchable.includes(kw.toLowerCase())
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (isMutedActor || matchedKeyword) {
|
|
79
|
-
if (filterMode === "warn") {
|
|
80
|
-
item._moderated = true;
|
|
81
|
-
item._moderationReason = isMutedActor ? "muted_account" : "muted_keyword";
|
|
82
|
-
if (matchedKeyword) item._moderationKeyword = matchedKeyword;
|
|
83
|
-
return true;
|
|
84
|
-
}
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return true;
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Get interaction state for liked/boosted indicators
|
|
93
|
-
const interactionsCol = application?.collections?.get("ap_interactions");
|
|
94
|
-
const interactionMap = {};
|
|
95
|
-
|
|
96
|
-
if (interactionsCol) {
|
|
97
|
-
const lookupUrls = new Set();
|
|
98
|
-
const objectUrlToUid = new Map();
|
|
99
|
-
|
|
100
|
-
for (const item of items) {
|
|
101
|
-
const uid = item.uid;
|
|
102
|
-
const displayUrl = item.url || item.originalUrl;
|
|
103
|
-
if (uid) { lookupUrls.add(uid); objectUrlToUid.set(uid, uid); }
|
|
104
|
-
if (displayUrl) { lookupUrls.add(displayUrl); objectUrlToUid.set(displayUrl, uid || displayUrl); }
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (lookupUrls.size > 0) {
|
|
108
|
-
const interactions = await interactionsCol
|
|
109
|
-
.find({ objectUrl: { $in: [...lookupUrls] } })
|
|
110
|
-
.toArray();
|
|
111
|
-
|
|
112
|
-
for (const interaction of interactions) {
|
|
113
|
-
const key = objectUrlToUid.get(interaction.objectUrl) || interaction.objectUrl;
|
|
114
|
-
if (!interactionMap[key]) interactionMap[key] = {};
|
|
115
|
-
interactionMap[key][interaction.type] = true;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
43
|
+
const { items, interactionMap } = await postProcessItems(result.items, {
|
|
44
|
+
moderation,
|
|
45
|
+
interactionsCol: application?.collections?.get("ap_interactions"),
|
|
46
|
+
});
|
|
119
47
|
|
|
120
|
-
// Check if this hashtag is followed
|
|
48
|
+
// Check if this hashtag is followed
|
|
121
49
|
const followedTagsCol = application?.collections?.get("ap_followed_tags");
|
|
122
50
|
let isFollowed = false;
|
|
123
51
|
if (followedTagsCol) {
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared item processing pipeline for the ActivityPub reader.
|
|
3
|
+
*
|
|
4
|
+
* Both the reader (inbox-sourced items) and explore (Mastodon API items)
|
|
5
|
+
* flow through these functions. This ensures every enhancement (emoji,
|
|
6
|
+
* quote stripping, moderation, etc.) is implemented once.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { stripQuoteReferenceHtml } from "./og-unfurl.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Post-process timeline items for rendering.
|
|
13
|
+
* Called after items are loaded from any source (MongoDB or Mastodon API).
|
|
14
|
+
*
|
|
15
|
+
* @param {Array} items - Timeline items (from DB or Mastodon API mapping)
|
|
16
|
+
* @param {object} [options]
|
|
17
|
+
* @param {object} [options.moderation] - { mutedUrls, mutedKeywords, blockedUrls, filterMode }
|
|
18
|
+
* @param {object} [options.interactionsCol] - MongoDB collection for interaction state
|
|
19
|
+
* @returns {Promise<{ items: Array, interactionMap: object }>}
|
|
20
|
+
*/
|
|
21
|
+
export async function postProcessItems(items, options = {}) {
|
|
22
|
+
// 1. Moderation filters (muted actors, keywords, blocked actors)
|
|
23
|
+
if (options.moderation) {
|
|
24
|
+
items = applyModerationFilters(items, options.moderation);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 2. Strip "RE:" paragraphs from items with quote embeds
|
|
28
|
+
stripQuoteReferences(items);
|
|
29
|
+
|
|
30
|
+
// 3. Build interaction map (likes/boosts) — empty when no collection
|
|
31
|
+
const interactionMap = options.interactionsCol
|
|
32
|
+
? await buildInteractionMap(items, options.interactionsCol)
|
|
33
|
+
: {};
|
|
34
|
+
|
|
35
|
+
return { items, interactionMap };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apply moderation filters to items.
|
|
40
|
+
* Blocked actors are always hidden. Muted actors/keywords are hidden or
|
|
41
|
+
* marked with a content warning depending on filterMode.
|
|
42
|
+
*
|
|
43
|
+
* @param {Array} items
|
|
44
|
+
* @param {object} moderation
|
|
45
|
+
* @param {string[]} moderation.mutedUrls
|
|
46
|
+
* @param {string[]} moderation.mutedKeywords
|
|
47
|
+
* @param {string[]} moderation.blockedUrls
|
|
48
|
+
* @param {string} moderation.filterMode - "hide" or "warn"
|
|
49
|
+
* @returns {Array}
|
|
50
|
+
*/
|
|
51
|
+
export function applyModerationFilters(items, { mutedUrls, mutedKeywords, blockedUrls, filterMode }) {
|
|
52
|
+
const blockedSet = new Set(blockedUrls);
|
|
53
|
+
const mutedSet = new Set(mutedUrls);
|
|
54
|
+
|
|
55
|
+
if (blockedSet.size === 0 && mutedSet.size === 0 && mutedKeywords.length === 0) {
|
|
56
|
+
return items;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return items.filter((item) => {
|
|
60
|
+
// Blocked actors are ALWAYS hidden
|
|
61
|
+
if (item.author?.url && blockedSet.has(item.author.url)) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check muted actor
|
|
66
|
+
const isMutedActor = item.author?.url && mutedSet.has(item.author.url);
|
|
67
|
+
|
|
68
|
+
// Check muted keywords against content, title, and summary
|
|
69
|
+
let matchedKeyword = null;
|
|
70
|
+
if (mutedKeywords.length > 0) {
|
|
71
|
+
const searchable = [item.content?.text, item.name, item.summary]
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.join(" ")
|
|
74
|
+
.toLowerCase();
|
|
75
|
+
if (searchable) {
|
|
76
|
+
matchedKeyword = mutedKeywords.find((kw) =>
|
|
77
|
+
searchable.includes(kw.toLowerCase()),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (isMutedActor || matchedKeyword) {
|
|
83
|
+
if (filterMode === "warn") {
|
|
84
|
+
// Mark for content warning instead of hiding
|
|
85
|
+
item._moderated = true;
|
|
86
|
+
item._moderationReason = isMutedActor ? "muted_account" : "muted_keyword";
|
|
87
|
+
if (matchedKeyword) {
|
|
88
|
+
item._moderationKeyword = matchedKeyword;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return true;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Strip "RE:" quote reference paragraphs from items that have quote embeds.
|
|
101
|
+
* Mutates items in place.
|
|
102
|
+
*
|
|
103
|
+
* @param {Array} items
|
|
104
|
+
*/
|
|
105
|
+
export function stripQuoteReferences(items) {
|
|
106
|
+
for (const item of items) {
|
|
107
|
+
const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
|
|
108
|
+
if (item.quote && quoteRef && item.content?.html) {
|
|
109
|
+
item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build interaction map (likes/boosts) for template rendering.
|
|
116
|
+
* Returns { [uid]: { like: true, boost: true } }.
|
|
117
|
+
*
|
|
118
|
+
* @param {Array} items
|
|
119
|
+
* @param {object} interactionsCol - MongoDB collection
|
|
120
|
+
* @returns {Promise<object>}
|
|
121
|
+
*/
|
|
122
|
+
export async function buildInteractionMap(items, interactionsCol) {
|
|
123
|
+
const interactionMap = {};
|
|
124
|
+
const lookupUrls = new Set();
|
|
125
|
+
const objectUrlToUid = new Map();
|
|
126
|
+
|
|
127
|
+
for (const item of items) {
|
|
128
|
+
const uid = item.uid;
|
|
129
|
+
const displayUrl = item.url || item.originalUrl;
|
|
130
|
+
|
|
131
|
+
if (uid) {
|
|
132
|
+
lookupUrls.add(uid);
|
|
133
|
+
objectUrlToUid.set(uid, uid);
|
|
134
|
+
}
|
|
135
|
+
if (displayUrl) {
|
|
136
|
+
lookupUrls.add(displayUrl);
|
|
137
|
+
objectUrlToUid.set(displayUrl, uid || displayUrl);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (lookupUrls.size === 0) return interactionMap;
|
|
142
|
+
|
|
143
|
+
const interactions = await interactionsCol
|
|
144
|
+
.find({ objectUrl: { $in: [...lookupUrls] } })
|
|
145
|
+
.toArray();
|
|
146
|
+
|
|
147
|
+
for (const interaction of interactions) {
|
|
148
|
+
const key = objectUrlToUid.get(interaction.objectUrl) || interaction.objectUrl;
|
|
149
|
+
if (!interactionMap[key]) interactionMap[key] = {};
|
|
150
|
+
interactionMap[key][interaction.type] = true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return interactionMap;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Filter items by tab type (reader-specific).
|
|
158
|
+
*
|
|
159
|
+
* @param {Array} items
|
|
160
|
+
* @param {string} tab - "notes", "articles", "boosts", "replies", "media", "all"
|
|
161
|
+
* @returns {Array}
|
|
162
|
+
*/
|
|
163
|
+
export function applyTabFilter(items, tab) {
|
|
164
|
+
if (tab === "replies") {
|
|
165
|
+
return items.filter((item) => item.inReplyTo);
|
|
166
|
+
}
|
|
167
|
+
if (tab === "media") {
|
|
168
|
+
return items.filter(
|
|
169
|
+
(item) =>
|
|
170
|
+
(item.photo && item.photo.length > 0) ||
|
|
171
|
+
(item.video && item.video.length > 0) ||
|
|
172
|
+
(item.audio && item.audio.length > 0),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return items;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Render items to HTML using ap-item-card.njk.
|
|
180
|
+
* Used by all API endpoints that return pre-rendered card HTML.
|
|
181
|
+
*
|
|
182
|
+
* @param {Array} items
|
|
183
|
+
* @param {object} request - Express request (for app.render)
|
|
184
|
+
* @param {object} templateData - Merged template context (locals, mountPath, csrfToken, interactionMap)
|
|
185
|
+
* @returns {Promise<string>}
|
|
186
|
+
*/
|
|
187
|
+
export async function renderItemCards(items, request, templateData) {
|
|
188
|
+
const htmlParts = await Promise.all(
|
|
189
|
+
items.map(
|
|
190
|
+
(item) =>
|
|
191
|
+
new Promise((resolve, reject) => {
|
|
192
|
+
request.app.render(
|
|
193
|
+
"partials/ap-item-card.njk",
|
|
194
|
+
{ ...templateData, item },
|
|
195
|
+
(err, html) => {
|
|
196
|
+
if (err) reject(err);
|
|
197
|
+
else resolve(html);
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
}),
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
return htmlParts.join("");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Load moderation data from MongoDB collections.
|
|
208
|
+
* Convenience wrapper to reduce boilerplate in controllers.
|
|
209
|
+
*
|
|
210
|
+
* @param {object} modCollections - { ap_muted, ap_blocked, ap_profile }
|
|
211
|
+
* @returns {Promise<object>} moderation data for postProcessItems()
|
|
212
|
+
*/
|
|
213
|
+
export async function loadModerationData(modCollections) {
|
|
214
|
+
// Dynamic import to avoid circular dependency
|
|
215
|
+
const { getMutedUrls, getMutedKeywords, getBlockedUrls, getFilterMode } =
|
|
216
|
+
await import("./storage/moderation.js");
|
|
217
|
+
|
|
218
|
+
const [mutedUrls, mutedKeywords, blockedUrls, filterMode] = await Promise.all([
|
|
219
|
+
getMutedUrls(modCollections),
|
|
220
|
+
getMutedKeywords(modCollections),
|
|
221
|
+
getBlockedUrls(modCollections),
|
|
222
|
+
getFilterMode(modCollections),
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
return { mutedUrls, mutedKeywords, blockedUrls, filterMode };
|
|
226
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
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",
|
|
@@ -247,11 +247,13 @@
|
|
|
247
247
|
{% if maxId %}
|
|
248
248
|
<div class="ap-load-more"
|
|
249
249
|
id="ap-explore-load-more"
|
|
250
|
-
data-
|
|
251
|
-
data-
|
|
252
|
-
data-
|
|
253
|
-
data-
|
|
254
|
-
|
|
250
|
+
data-cursor="{{ maxId }}"
|
|
251
|
+
data-api-url="{{ mountPath }}/admin/reader/api/explore"
|
|
252
|
+
data-cursor-param="max_id"
|
|
253
|
+
data-cursor-field="maxId"
|
|
254
|
+
data-timeline-id="ap-explore-timeline"
|
|
255
|
+
data-extra-params='{{ { instance: instance, scope: scope, hashtag: hashtag } | dump }}'
|
|
256
|
+
x-data="apInfiniteScroll()"
|
|
255
257
|
x-init="init()">
|
|
256
258
|
<div class="ap-load-more__sentinel" x-ref="sentinel"></div>
|
|
257
259
|
<button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
|
|
@@ -140,12 +140,15 @@
|
|
|
140
140
|
{% if before %}
|
|
141
141
|
<div class="ap-load-more"
|
|
142
142
|
id="ap-load-more"
|
|
143
|
-
data-
|
|
144
|
-
data-
|
|
145
|
-
data-
|
|
143
|
+
data-cursor="{{ before }}"
|
|
144
|
+
data-api-url="{{ mountPath }}/admin/reader/api/timeline"
|
|
145
|
+
data-cursor-param="before"
|
|
146
|
+
data-cursor-field="before"
|
|
147
|
+
data-timeline-id="ap-timeline"
|
|
148
|
+
data-extra-params='{{ { tab: tab } | dump }}'
|
|
149
|
+
data-hide-pagination="ap-reader-pagination"
|
|
146
150
|
x-data="apInfiniteScroll()"
|
|
147
|
-
x-init="init()"
|
|
148
|
-
@ap-append-items.window="appendItems($event.detail)">
|
|
151
|
+
x-init="init()">
|
|
149
152
|
<div class="ap-load-more__sentinel" x-ref="sentinel"></div>
|
|
150
153
|
<button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
|
|
151
154
|
<span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
{# Timeline items #}
|
|
37
37
|
{% if items.length > 0 %}
|
|
38
38
|
<div class="ap-timeline"
|
|
39
|
+
id="ap-timeline"
|
|
39
40
|
data-mount-path="{{ mountPath }}"
|
|
40
41
|
data-tag="{{ hashtag }}"
|
|
41
42
|
data-before="{{ before if before else '' }}">
|
|
@@ -64,12 +65,15 @@
|
|
|
64
65
|
{% if before %}
|
|
65
66
|
<div class="ap-load-more"
|
|
66
67
|
id="ap-load-more"
|
|
67
|
-
data-
|
|
68
|
-
data-
|
|
69
|
-
data-
|
|
68
|
+
data-cursor="{{ before }}"
|
|
69
|
+
data-api-url="{{ mountPath }}/admin/reader/api/timeline"
|
|
70
|
+
data-cursor-param="before"
|
|
71
|
+
data-cursor-field="before"
|
|
72
|
+
data-timeline-id="ap-timeline"
|
|
73
|
+
data-extra-params='{{ { tag: hashtag } | dump }}'
|
|
74
|
+
data-hide-pagination="ap-tag-pagination"
|
|
70
75
|
x-data="apInfiniteScroll()"
|
|
71
|
-
x-init="init()"
|
|
72
|
-
@ap-append-items.window="appendItems($event.detail)">
|
|
76
|
+
x-init="init()">
|
|
73
77
|
<div class="ap-load-more__sentinel" x-ref="sentinel"></div>
|
|
74
78
|
<button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
|
|
75
79
|
<span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
|