@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.
@@ -1,121 +1,55 @@
1
1
  /**
2
- * Infinite scroll — AlpineJS component for AJAX load-more on the timeline
3
- * Registers the `apInfiniteScroll` Alpine data component.
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("apExploreScroll", () => ({
19
+ Alpine.data("apInfiniteScroll", () => ({
9
20
  loading: false,
10
21
  done: false,
11
- maxId: null,
12
- instance: "",
13
- scope: "local",
14
- hashtag: "",
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.maxId = el.dataset.maxId || null;
20
- this.instance = el.dataset.instance || "";
21
- this.scope = el.dataset.scope || "local";
22
- this.hashtag = el.dataset.hashtag || "";
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
- const res = await fetch(
65
- `${mountPath}/admin/reader/api/explore?${params.toString()}`,
66
- { headers: { Accept: "application/json" } }
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 paginationEl =
112
- document.getElementById("ap-reader-pagination") ||
113
- document.getElementById("ap-tag-pagination");
114
- if (paginationEl) {
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.before) {
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.before) return;
75
+ if (this.loading || this.done || !this.cursor) return;
142
76
 
143
77
  this.loading = true;
144
78
 
145
- const timeline = document.getElementById("ap-timeline");
146
- const mountPath = timeline ? timeline.dataset.mountPath : "";
147
-
148
- const params = new URLSearchParams({ before: this.before });
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
- `${mountPath}/admin/reader/api/timeline?${params.toString()}`,
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.before) {
168
- this.before = data.before;
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 (same logic as readerController)
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
- // Client-side tab filtering for types not supported by storage
49
- let items = result.items;
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
- // Apply moderation filters
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 [mutedUrls, mutedKeywords, blockedUrls, filterMode] =
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
- // Strip "RE:" paragraphs from items that have quote embeds
139
- for (const item of items) {
140
- const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
141
- if (item.quote && quoteRef && item.content?.html) {
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: htmlParts.join(""),
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 { stripQuoteReferenceHtml } from "../og-unfurl.js";
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 controller = new AbortController();
82
- const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
83
-
84
- const fetchRes = await fetch(apiUrl.toString(), {
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
- // Build API URL: hashtag timeline or public timeline
161
- const isLocal = scope === "local";
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 templateData = {
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 { stripQuoteReferenceHtml } from "../og-unfurl.js";
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 items = pageItems.map(({ status, domain }) =>
183
+ const rawItems = pageItems.map(({ status, domain }) =>
184
184
  mapMastodonStatusToItem(status, domain)
185
185
  );
186
186
 
187
- // Strip "RE:" paragraphs from items that have quote embeds
188
- for (const item of items) {
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 templateData = {
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: htmlParts.join(""),
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 { stripQuoteReferenceHtml } from "../og-unfurl.js";
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
- // Apply client-side filtering for tabs not supported by storage layer
72
- let items = result.items;
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
- // Apply moderation filters (muted actors, keywords, blocked actors)
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 [mutedUrls, mutedKeywords, blockedUrls, filterMode] =
91
- await Promise.all([
92
- getMutedUrls(modCollections),
93
- getMutedKeywords(modCollections),
94
- getBlockedUrls(modCollections),
95
- getFilterMode(modCollections),
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
- // Apply moderation filters (same as main reader)
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 [mutedUrls, mutedKeywords, blockedUrls, filterMode] =
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
- if (blockedSet.size > 0 || mutedSet.size > 0 || mutedKeywords.length > 0) {
58
- items = items.filter((item) => {
59
- if (item.author?.url && blockedSet.has(item.author.url)) {
60
- return false;
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 (Task 7 will populate ap_followed_tags)
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.4.5",
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-max-id="{{ maxId }}"
251
- data-instance="{{ instance }}"
252
- data-scope="{{ scope }}"
253
- data-hashtag="{{ hashtag }}"
254
- x-data="apExploreScroll()"
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-before="{{ before }}"
144
- data-tab="{{ tab }}"
145
- data-tag=""
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-before="{{ before }}"
68
- data-tab=""
69
- data-tag="{{ hashtag }}"
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>