@rmdes/indiekit-endpoint-activitypub 2.0.27 → 2.0.29
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-autocomplete.js +214 -0
- package/assets/reader-infinite-scroll.js +183 -0
- package/assets/reader.css +438 -0
- package/index.js +43 -0
- package/lib/controllers/api-timeline.js +170 -0
- package/lib/controllers/explore.js +364 -0
- package/lib/controllers/follow-tag.js +62 -0
- package/lib/controllers/reader.js +11 -0
- package/lib/controllers/tag-timeline.js +147 -0
- package/lib/fedidb.js +195 -0
- package/lib/inbox-listeners.js +27 -0
- package/lib/migrations/separate-mentions.js +88 -0
- package/lib/storage/followed-tags.js +65 -0
- package/lib/storage/timeline.js +15 -2
- package/lib/timeline-store.js +18 -5
- package/locales/en.json +32 -5
- package/package.json +1 -1
- package/views/activitypub-explore.njk +126 -0
- package/views/activitypub-reader.njk +80 -8
- package/views/activitypub-tag-timeline.njk +86 -0
- package/views/layouts/ap-reader.njk +6 -1
- package/views/partials/ap-item-card.njk +20 -5
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autocomplete — Alpine.js components for FediDB-powered search suggestions.
|
|
3
|
+
* Registers `apInstanceSearch` for the explore page instance input.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
document.addEventListener("alpine:init", () => {
|
|
7
|
+
// eslint-disable-next-line no-undef
|
|
8
|
+
Alpine.data("apInstanceSearch", (mountPath) => ({
|
|
9
|
+
query: "",
|
|
10
|
+
suggestions: [],
|
|
11
|
+
showResults: false,
|
|
12
|
+
highlighted: -1,
|
|
13
|
+
abortController: null,
|
|
14
|
+
|
|
15
|
+
init() {
|
|
16
|
+
// Pick up server-rendered value (when returning to page with instance already loaded)
|
|
17
|
+
const input = this.$refs.input;
|
|
18
|
+
if (input && input.getAttribute("value")) {
|
|
19
|
+
this.query = input.getAttribute("value");
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
// Debounced search triggered by x-on:input
|
|
24
|
+
async search() {
|
|
25
|
+
const q = (this.query || "").trim();
|
|
26
|
+
if (q.length < 2) {
|
|
27
|
+
this.suggestions = [];
|
|
28
|
+
this.showResults = false;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Cancel any in-flight request
|
|
33
|
+
if (this.abortController) {
|
|
34
|
+
this.abortController.abort();
|
|
35
|
+
}
|
|
36
|
+
this.abortController = new AbortController();
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(
|
|
40
|
+
`${mountPath}/admin/reader/api/instances?q=${encodeURIComponent(q)}`,
|
|
41
|
+
{ signal: this.abortController.signal }
|
|
42
|
+
);
|
|
43
|
+
if (!res.ok) return;
|
|
44
|
+
|
|
45
|
+
const data = await res.json();
|
|
46
|
+
// Mark _timelineStatus as undefined (not yet checked)
|
|
47
|
+
this.suggestions = data.map((item) => ({
|
|
48
|
+
...item,
|
|
49
|
+
_timelineStatus: undefined,
|
|
50
|
+
}));
|
|
51
|
+
this.highlighted = -1;
|
|
52
|
+
this.showResults = this.suggestions.length > 0;
|
|
53
|
+
|
|
54
|
+
// Fire timeline support checks in parallel (non-blocking)
|
|
55
|
+
this.checkTimelineSupport();
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (err.name !== "AbortError") {
|
|
58
|
+
this.suggestions = [];
|
|
59
|
+
this.showResults = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
// Check timeline support for each suggestion (background, non-blocking)
|
|
65
|
+
async checkTimelineSupport() {
|
|
66
|
+
const items = [...this.suggestions];
|
|
67
|
+
for (const item of items) {
|
|
68
|
+
// Only check if still in the current suggestions list
|
|
69
|
+
const match = this.suggestions.find((s) => s.domain === item.domain);
|
|
70
|
+
if (!match) continue;
|
|
71
|
+
|
|
72
|
+
match._timelineStatus = "checking";
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch(
|
|
76
|
+
`${mountPath}/admin/reader/api/instance-check?domain=${encodeURIComponent(item.domain)}`
|
|
77
|
+
);
|
|
78
|
+
if (!res.ok) continue;
|
|
79
|
+
|
|
80
|
+
const data = await res.json();
|
|
81
|
+
// Update the item in the current suggestions (if still present)
|
|
82
|
+
const current = this.suggestions.find((s) => s.domain === item.domain);
|
|
83
|
+
if (current) {
|
|
84
|
+
current._timelineStatus = data.supported;
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
const current = this.suggestions.find((s) => s.domain === item.domain);
|
|
88
|
+
if (current) {
|
|
89
|
+
current._timelineStatus = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
selectItem(item) {
|
|
96
|
+
this.query = item.domain;
|
|
97
|
+
this.showResults = false;
|
|
98
|
+
this.suggestions = [];
|
|
99
|
+
this.$refs.input.focus();
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
close() {
|
|
103
|
+
this.showResults = false;
|
|
104
|
+
this.highlighted = -1;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
highlightNext() {
|
|
108
|
+
if (!this.showResults || this.suggestions.length === 0) return;
|
|
109
|
+
this.highlighted = (this.highlighted + 1) % this.suggestions.length;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
highlightPrev() {
|
|
113
|
+
if (!this.showResults || this.suggestions.length === 0) return;
|
|
114
|
+
this.highlighted =
|
|
115
|
+
this.highlighted <= 0
|
|
116
|
+
? this.suggestions.length - 1
|
|
117
|
+
: this.highlighted - 1;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
selectHighlighted(event) {
|
|
121
|
+
if (this.showResults && this.highlighted >= 0 && this.suggestions[this.highlighted]) {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
this.selectItem(this.suggestions[this.highlighted]);
|
|
124
|
+
}
|
|
125
|
+
// Otherwise let the form submit naturally
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
onSubmit() {
|
|
129
|
+
this.close();
|
|
130
|
+
},
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
// eslint-disable-next-line no-undef
|
|
134
|
+
Alpine.data("apPopularAccounts", (mountPath) => ({
|
|
135
|
+
query: "",
|
|
136
|
+
suggestions: [],
|
|
137
|
+
allAccounts: [],
|
|
138
|
+
showResults: false,
|
|
139
|
+
highlighted: -1,
|
|
140
|
+
loaded: false,
|
|
141
|
+
|
|
142
|
+
// Load popular accounts on first focus (lazy)
|
|
143
|
+
async loadAccounts() {
|
|
144
|
+
if (this.loaded) return;
|
|
145
|
+
this.loaded = true;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const res = await fetch(`${mountPath}/admin/reader/api/popular-accounts`);
|
|
149
|
+
if (!res.ok) return;
|
|
150
|
+
this.allAccounts = await res.json();
|
|
151
|
+
} catch {
|
|
152
|
+
// Non-critical
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
// Filter locally from preloaded list
|
|
157
|
+
filterAccounts() {
|
|
158
|
+
const q = (this.query || "").trim().toLowerCase();
|
|
159
|
+
if (q.length < 1 || this.allAccounts.length === 0) {
|
|
160
|
+
this.suggestions = [];
|
|
161
|
+
this.showResults = false;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this.suggestions = this.allAccounts
|
|
166
|
+
.filter(
|
|
167
|
+
(a) =>
|
|
168
|
+
a.username.toLowerCase().includes(q) ||
|
|
169
|
+
a.name.toLowerCase().includes(q) ||
|
|
170
|
+
a.domain.toLowerCase().includes(q) ||
|
|
171
|
+
a.handle.toLowerCase().includes(q)
|
|
172
|
+
)
|
|
173
|
+
.slice(0, 8);
|
|
174
|
+
this.highlighted = -1;
|
|
175
|
+
this.showResults = this.suggestions.length > 0;
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
selectItem(item) {
|
|
179
|
+
this.query = item.handle;
|
|
180
|
+
this.showResults = false;
|
|
181
|
+
this.suggestions = [];
|
|
182
|
+
this.$refs.input.focus();
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
close() {
|
|
186
|
+
this.showResults = false;
|
|
187
|
+
this.highlighted = -1;
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
highlightNext() {
|
|
191
|
+
if (!this.showResults || this.suggestions.length === 0) return;
|
|
192
|
+
this.highlighted = (this.highlighted + 1) % this.suggestions.length;
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
highlightPrev() {
|
|
196
|
+
if (!this.showResults || this.suggestions.length === 0) return;
|
|
197
|
+
this.highlighted =
|
|
198
|
+
this.highlighted <= 0
|
|
199
|
+
? this.suggestions.length - 1
|
|
200
|
+
: this.highlighted - 1;
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
selectHighlighted(event) {
|
|
204
|
+
if (this.showResults && this.highlighted >= 0 && this.suggestions[this.highlighted]) {
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
this.selectItem(this.suggestions[this.highlighted]);
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
onSubmit() {
|
|
211
|
+
this.close();
|
|
212
|
+
},
|
|
213
|
+
}));
|
|
214
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infinite scroll — AlpineJS component for AJAX load-more on the timeline
|
|
3
|
+
* Registers the `apInfiniteScroll` Alpine data component.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
document.addEventListener("alpine:init", () => {
|
|
7
|
+
// eslint-disable-next-line no-undef
|
|
8
|
+
Alpine.data("apExploreScroll", () => ({
|
|
9
|
+
loading: false,
|
|
10
|
+
done: false,
|
|
11
|
+
maxId: null,
|
|
12
|
+
instance: "",
|
|
13
|
+
scope: "local",
|
|
14
|
+
observer: null,
|
|
15
|
+
|
|
16
|
+
init() {
|
|
17
|
+
const el = this.$el;
|
|
18
|
+
this.maxId = el.dataset.maxId || null;
|
|
19
|
+
this.instance = el.dataset.instance || "";
|
|
20
|
+
this.scope = el.dataset.scope || "local";
|
|
21
|
+
|
|
22
|
+
if (!this.maxId) {
|
|
23
|
+
this.done = true;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
this.observer = new IntersectionObserver(
|
|
28
|
+
(entries) => {
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (entry.isIntersecting && !this.loading && !this.done) {
|
|
31
|
+
this.loadMore();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{ rootMargin: "200px" }
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
if (this.$refs.sentinel) {
|
|
39
|
+
this.observer.observe(this.$refs.sentinel);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async loadMore() {
|
|
44
|
+
if (this.loading || this.done || !this.maxId) return;
|
|
45
|
+
|
|
46
|
+
this.loading = true;
|
|
47
|
+
|
|
48
|
+
const timeline = document.getElementById("ap-explore-timeline");
|
|
49
|
+
const mountPath = timeline ? timeline.dataset.mountPath : "";
|
|
50
|
+
|
|
51
|
+
const params = new URLSearchParams({
|
|
52
|
+
instance: this.instance,
|
|
53
|
+
scope: this.scope,
|
|
54
|
+
max_id: this.maxId,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(
|
|
59
|
+
`${mountPath}/admin/reader/api/explore?${params.toString()}`,
|
|
60
|
+
{ headers: { Accept: "application/json" } }
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
64
|
+
|
|
65
|
+
const data = await res.json();
|
|
66
|
+
|
|
67
|
+
if (data.html && timeline) {
|
|
68
|
+
timeline.insertAdjacentHTML("beforeend", data.html);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (data.maxId) {
|
|
72
|
+
this.maxId = data.maxId;
|
|
73
|
+
} else {
|
|
74
|
+
this.done = true;
|
|
75
|
+
if (this.observer) this.observer.disconnect();
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error("[ap-explore-scroll] load failed:", err.message);
|
|
79
|
+
} finally {
|
|
80
|
+
this.loading = false;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
destroy() {
|
|
85
|
+
if (this.observer) this.observer.disconnect();
|
|
86
|
+
},
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
// eslint-disable-next-line no-undef
|
|
90
|
+
Alpine.data("apInfiniteScroll", () => ({
|
|
91
|
+
loading: false,
|
|
92
|
+
done: false,
|
|
93
|
+
before: null,
|
|
94
|
+
tab: "",
|
|
95
|
+
tag: "",
|
|
96
|
+
observer: null,
|
|
97
|
+
|
|
98
|
+
init() {
|
|
99
|
+
const el = this.$el;
|
|
100
|
+
this.before = el.dataset.before || null;
|
|
101
|
+
this.tab = el.dataset.tab || "";
|
|
102
|
+
this.tag = el.dataset.tag || "";
|
|
103
|
+
|
|
104
|
+
// Hide the no-JS pagination fallback now that JS is active
|
|
105
|
+
const paginationEl =
|
|
106
|
+
document.getElementById("ap-reader-pagination") ||
|
|
107
|
+
document.getElementById("ap-tag-pagination");
|
|
108
|
+
if (paginationEl) {
|
|
109
|
+
paginationEl.style.display = "none";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!this.before) {
|
|
113
|
+
this.done = true;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Set up IntersectionObserver to auto-load when sentinel comes into view
|
|
118
|
+
this.observer = new IntersectionObserver(
|
|
119
|
+
(entries) => {
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (entry.isIntersecting && !this.loading && !this.done) {
|
|
122
|
+
this.loadMore();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
{ rootMargin: "200px" }
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
if (this.$refs.sentinel) {
|
|
130
|
+
this.observer.observe(this.$refs.sentinel);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async loadMore() {
|
|
135
|
+
if (this.loading || this.done || !this.before) return;
|
|
136
|
+
|
|
137
|
+
this.loading = true;
|
|
138
|
+
|
|
139
|
+
const timeline = document.getElementById("ap-timeline");
|
|
140
|
+
const mountPath = timeline ? timeline.dataset.mountPath : "";
|
|
141
|
+
|
|
142
|
+
const params = new URLSearchParams({ before: this.before });
|
|
143
|
+
if (this.tab) params.set("tab", this.tab);
|
|
144
|
+
if (this.tag) params.set("tag", this.tag);
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const res = await fetch(
|
|
148
|
+
`${mountPath}/admin/reader/api/timeline?${params.toString()}`,
|
|
149
|
+
{ headers: { Accept: "application/json" } }
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
153
|
+
|
|
154
|
+
const data = await res.json();
|
|
155
|
+
|
|
156
|
+
if (data.html && timeline) {
|
|
157
|
+
// Append the returned pre-rendered HTML
|
|
158
|
+
timeline.insertAdjacentHTML("beforeend", data.html);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (data.before) {
|
|
162
|
+
this.before = data.before;
|
|
163
|
+
} else {
|
|
164
|
+
// No more items
|
|
165
|
+
this.done = true;
|
|
166
|
+
if (this.observer) this.observer.disconnect();
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.error("[ap-infinite-scroll] load failed:", err.message);
|
|
170
|
+
} finally {
|
|
171
|
+
this.loading = false;
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
appendItems(/* detail */) {
|
|
176
|
+
// Custom event hook — not used in this implementation but kept for extensibility
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
destroy() {
|
|
180
|
+
if (this.observer) this.observer.disconnect();
|
|
181
|
+
},
|
|
182
|
+
}));
|
|
183
|
+
});
|