@rmdes/indiekit-endpoint-activitypub 2.0.28 → 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.css +156 -0
- package/index.js +10 -1
- package/lib/controllers/explore.js +71 -0
- package/lib/fedidb.js +195 -0
- package/locales/en.json +6 -2
- package/package.json +1 -1
- package/views/activitypub-explore.njk +55 -11
- package/views/activitypub-reader.njk +38 -5
- package/views/layouts/ap-reader.njk +2 -0
|
@@ -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
|
+
});
|
package/assets/reader.css
CHANGED
|
@@ -1838,6 +1838,162 @@
|
|
|
1838
1838
|
}
|
|
1839
1839
|
}
|
|
1840
1840
|
|
|
1841
|
+
/* ---------- Autocomplete dropdown ---------- */
|
|
1842
|
+
|
|
1843
|
+
.ap-explore-autocomplete {
|
|
1844
|
+
flex: 1;
|
|
1845
|
+
min-width: 0;
|
|
1846
|
+
position: relative;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
.ap-explore-autocomplete__dropdown {
|
|
1850
|
+
background: var(--color-background);
|
|
1851
|
+
border: var(--border-width-thin) solid var(--color-outline);
|
|
1852
|
+
border-radius: var(--border-radius-small);
|
|
1853
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
1854
|
+
left: 0;
|
|
1855
|
+
max-height: 320px;
|
|
1856
|
+
overflow-y: auto;
|
|
1857
|
+
position: absolute;
|
|
1858
|
+
right: 0;
|
|
1859
|
+
top: 100%;
|
|
1860
|
+
z-index: 100;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
.ap-explore-autocomplete__item {
|
|
1864
|
+
align-items: center;
|
|
1865
|
+
background: none;
|
|
1866
|
+
border: none;
|
|
1867
|
+
color: var(--color-on-background);
|
|
1868
|
+
cursor: pointer;
|
|
1869
|
+
display: flex;
|
|
1870
|
+
font-family: inherit;
|
|
1871
|
+
font-size: var(--font-size-s);
|
|
1872
|
+
gap: var(--space-s);
|
|
1873
|
+
padding: var(--space-s) var(--space-m);
|
|
1874
|
+
text-align: left;
|
|
1875
|
+
width: 100%;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
.ap-explore-autocomplete__item:hover,
|
|
1879
|
+
.ap-explore-autocomplete__item--highlighted {
|
|
1880
|
+
background: var(--color-offset);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
.ap-explore-autocomplete__domain {
|
|
1884
|
+
flex-shrink: 0;
|
|
1885
|
+
font-weight: 600;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
.ap-explore-autocomplete__meta {
|
|
1889
|
+
color: var(--color-on-offset);
|
|
1890
|
+
display: flex;
|
|
1891
|
+
flex: 1;
|
|
1892
|
+
gap: var(--space-xs);
|
|
1893
|
+
min-width: 0;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
.ap-explore-autocomplete__software {
|
|
1897
|
+
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
1898
|
+
border-radius: var(--border-radius-small);
|
|
1899
|
+
font-size: var(--font-size-xs);
|
|
1900
|
+
padding: 1px 6px;
|
|
1901
|
+
white-space: nowrap;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
.ap-explore-autocomplete__mau {
|
|
1905
|
+
font-size: var(--font-size-xs);
|
|
1906
|
+
white-space: nowrap;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
.ap-explore-autocomplete__status {
|
|
1910
|
+
flex-shrink: 0;
|
|
1911
|
+
font-size: var(--font-size-s);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
.ap-explore-autocomplete__checking {
|
|
1915
|
+
opacity: 0.5;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
/* ---------- Popular accounts autocomplete ---------- */
|
|
1919
|
+
|
|
1920
|
+
.ap-lookup-autocomplete {
|
|
1921
|
+
flex: 1;
|
|
1922
|
+
min-width: 0;
|
|
1923
|
+
position: relative;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
.ap-lookup-autocomplete__dropdown {
|
|
1927
|
+
background: var(--color-background);
|
|
1928
|
+
border: var(--border-width-thin) solid var(--color-outline);
|
|
1929
|
+
border-radius: var(--border-radius-small);
|
|
1930
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
1931
|
+
left: 0;
|
|
1932
|
+
max-height: 320px;
|
|
1933
|
+
overflow-y: auto;
|
|
1934
|
+
position: absolute;
|
|
1935
|
+
right: 0;
|
|
1936
|
+
top: 100%;
|
|
1937
|
+
z-index: 100;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
.ap-lookup-autocomplete__item {
|
|
1941
|
+
align-items: center;
|
|
1942
|
+
background: none;
|
|
1943
|
+
border: none;
|
|
1944
|
+
color: var(--color-on-background);
|
|
1945
|
+
cursor: pointer;
|
|
1946
|
+
display: flex;
|
|
1947
|
+
font-family: inherit;
|
|
1948
|
+
font-size: var(--font-size-s);
|
|
1949
|
+
gap: var(--space-s);
|
|
1950
|
+
padding: var(--space-s) var(--space-m);
|
|
1951
|
+
text-align: left;
|
|
1952
|
+
width: 100%;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
.ap-lookup-autocomplete__item:hover,
|
|
1956
|
+
.ap-lookup-autocomplete__item--highlighted {
|
|
1957
|
+
background: var(--color-offset);
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
.ap-lookup-autocomplete__avatar {
|
|
1961
|
+
border-radius: 50%;
|
|
1962
|
+
flex-shrink: 0;
|
|
1963
|
+
height: 28px;
|
|
1964
|
+
object-fit: cover;
|
|
1965
|
+
width: 28px;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
.ap-lookup-autocomplete__info {
|
|
1969
|
+
display: flex;
|
|
1970
|
+
flex: 1;
|
|
1971
|
+
flex-direction: column;
|
|
1972
|
+
min-width: 0;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
.ap-lookup-autocomplete__name {
|
|
1976
|
+
font-weight: 600;
|
|
1977
|
+
overflow: hidden;
|
|
1978
|
+
text-overflow: ellipsis;
|
|
1979
|
+
white-space: nowrap;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
.ap-lookup-autocomplete__handle {
|
|
1983
|
+
color: var(--color-on-offset);
|
|
1984
|
+
font-size: var(--font-size-xs);
|
|
1985
|
+
overflow: hidden;
|
|
1986
|
+
text-overflow: ellipsis;
|
|
1987
|
+
white-space: nowrap;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
.ap-lookup-autocomplete__followers {
|
|
1991
|
+
color: var(--color-on-offset);
|
|
1992
|
+
flex-shrink: 0;
|
|
1993
|
+
font-size: var(--font-size-xs);
|
|
1994
|
+
white-space: nowrap;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1841
1997
|
/* Replies — indented from the other side */
|
|
1842
1998
|
.ap-post-detail__replies {
|
|
1843
1999
|
margin-left: var(--space-l);
|
package/index.js
CHANGED
|
@@ -61,7 +61,13 @@ import {
|
|
|
61
61
|
import { resolveController } from "./lib/controllers/resolve.js";
|
|
62
62
|
import { tagTimelineController } from "./lib/controllers/tag-timeline.js";
|
|
63
63
|
import { apiTimelineController } from "./lib/controllers/api-timeline.js";
|
|
64
|
-
import {
|
|
64
|
+
import {
|
|
65
|
+
exploreController,
|
|
66
|
+
exploreApiController,
|
|
67
|
+
instanceSearchApiController,
|
|
68
|
+
instanceCheckApiController,
|
|
69
|
+
popularAccountsApiController,
|
|
70
|
+
} from "./lib/controllers/explore.js";
|
|
65
71
|
import { followTagController, unfollowTagController } from "./lib/controllers/follow-tag.js";
|
|
66
72
|
import { publicProfileController } from "./lib/controllers/public-profile.js";
|
|
67
73
|
import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.js";
|
|
@@ -227,6 +233,9 @@ export default class ActivityPubEndpoint {
|
|
|
227
233
|
router.get("/admin/reader/api/timeline", apiTimelineController(mp));
|
|
228
234
|
router.get("/admin/reader/explore", exploreController(mp));
|
|
229
235
|
router.get("/admin/reader/api/explore", exploreApiController(mp));
|
|
236
|
+
router.get("/admin/reader/api/instances", instanceSearchApiController(mp));
|
|
237
|
+
router.get("/admin/reader/api/instance-check", instanceCheckApiController(mp));
|
|
238
|
+
router.get("/admin/reader/api/popular-accounts", popularAccountsApiController(mp));
|
|
230
239
|
router.post("/admin/reader/follow-tag", followTagController(mp));
|
|
231
240
|
router.post("/admin/reader/unfollow-tag", unfollowTagController(mp));
|
|
232
241
|
router.get("/admin/reader/notifications", notificationsController(mp));
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import sanitizeHtml from "sanitize-html";
|
|
9
9
|
import { sanitizeContent } from "../timeline-store.js";
|
|
10
|
+
import { searchInstances, checkInstanceTimeline, getPopularAccounts } from "../fedidb.js";
|
|
10
11
|
|
|
11
12
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
12
13
|
const MAX_RESULTS = 20;
|
|
@@ -291,3 +292,73 @@ export function exploreApiController(mountPath) {
|
|
|
291
292
|
}
|
|
292
293
|
};
|
|
293
294
|
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* AJAX API endpoint for instance autocomplete.
|
|
298
|
+
* Returns JSON array of matching instances from FediDB.
|
|
299
|
+
*/
|
|
300
|
+
export function instanceSearchApiController(mountPath) {
|
|
301
|
+
return async (request, response, next) => {
|
|
302
|
+
try {
|
|
303
|
+
const q = (request.query.q || "").trim();
|
|
304
|
+
if (!q || q.length < 2) {
|
|
305
|
+
return response.json([]);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const { application } = request.app.locals;
|
|
309
|
+
const kvCollection = application?.collections?.get("ap_kv") || null;
|
|
310
|
+
|
|
311
|
+
const results = await searchInstances(kvCollection, q, 8);
|
|
312
|
+
response.json(results);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
next(error);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* AJAX API endpoint to check if an instance supports public timeline exploration.
|
|
321
|
+
* Returns JSON { supported: boolean, error: string|null }.
|
|
322
|
+
*/
|
|
323
|
+
export function instanceCheckApiController(mountPath) {
|
|
324
|
+
return async (request, response, next) => {
|
|
325
|
+
try {
|
|
326
|
+
const domain = (request.query.domain || "").trim().toLowerCase();
|
|
327
|
+
if (!domain) {
|
|
328
|
+
return response.status(400).json({ supported: false, error: "Missing domain" });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Validate domain to prevent SSRF
|
|
332
|
+
const validated = validateInstance(domain);
|
|
333
|
+
if (!validated) {
|
|
334
|
+
return response.status(400).json({ supported: false, error: "Invalid domain" });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const { application } = request.app.locals;
|
|
338
|
+
const kvCollection = application?.collections?.get("ap_kv") || null;
|
|
339
|
+
|
|
340
|
+
const result = await checkInstanceTimeline(kvCollection, validated);
|
|
341
|
+
response.json(result);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
next(error);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* AJAX API endpoint for popular fediverse accounts.
|
|
350
|
+
* Returns the full cached list; client-side filtering via Alpine.js.
|
|
351
|
+
*/
|
|
352
|
+
export function popularAccountsApiController(mountPath) {
|
|
353
|
+
return async (request, response, next) => {
|
|
354
|
+
try {
|
|
355
|
+
const { application } = request.app.locals;
|
|
356
|
+
const kvCollection = application?.collections?.get("ap_kv") || null;
|
|
357
|
+
|
|
358
|
+
const accounts = await getPopularAccounts(kvCollection, 50);
|
|
359
|
+
response.json(accounts);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
next(error);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
}
|
package/lib/fedidb.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FediDB API client with MongoDB caching.
|
|
3
|
+
*
|
|
4
|
+
* Wraps https://api.fedidb.org/v1/ endpoints:
|
|
5
|
+
* - /servers?q=... — search known fediverse instances
|
|
6
|
+
* - /popular-accounts — top accounts by follower count
|
|
7
|
+
*
|
|
8
|
+
* Responses are cached in ap_kv to avoid hitting the API on every keystroke.
|
|
9
|
+
* Cache TTL: 24 hours for both datasets.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const API_BASE = "https://api.fedidb.org/v1";
|
|
13
|
+
const FETCH_TIMEOUT_MS = 8_000;
|
|
14
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fetch with timeout helper.
|
|
18
|
+
* @param {string} url
|
|
19
|
+
* @returns {Promise<Response>}
|
|
20
|
+
*/
|
|
21
|
+
async function fetchWithTimeout(url) {
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(url, {
|
|
26
|
+
headers: { Accept: "application/json" },
|
|
27
|
+
signal: controller.signal,
|
|
28
|
+
});
|
|
29
|
+
return res;
|
|
30
|
+
} finally {
|
|
31
|
+
clearTimeout(timeoutId);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get cached data from ap_kv, or null if expired/missing.
|
|
37
|
+
* @param {object} kvCollection - MongoDB ap_kv collection
|
|
38
|
+
* @param {string} cacheKey - Key to look up
|
|
39
|
+
* @returns {Promise<object|null>} Cached data or null
|
|
40
|
+
*/
|
|
41
|
+
async function getFromCache(kvCollection, cacheKey) {
|
|
42
|
+
if (!kvCollection) return null;
|
|
43
|
+
try {
|
|
44
|
+
const doc = await kvCollection.findOne({ _id: cacheKey });
|
|
45
|
+
if (!doc?.value?.data) return null;
|
|
46
|
+
const age = Date.now() - (doc.value.cachedAt || 0);
|
|
47
|
+
if (age > CACHE_TTL_MS) return null;
|
|
48
|
+
return doc.value.data;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Write data to ap_kv cache.
|
|
56
|
+
* @param {object} kvCollection - MongoDB ap_kv collection
|
|
57
|
+
* @param {string} cacheKey - Key to store under
|
|
58
|
+
* @param {object} data - Data to cache
|
|
59
|
+
*/
|
|
60
|
+
async function writeToCache(kvCollection, cacheKey, data) {
|
|
61
|
+
if (!kvCollection) return;
|
|
62
|
+
try {
|
|
63
|
+
await kvCollection.updateOne(
|
|
64
|
+
{ _id: cacheKey },
|
|
65
|
+
{ $set: { value: { data, cachedAt: Date.now() } } },
|
|
66
|
+
{ upsert: true }
|
|
67
|
+
);
|
|
68
|
+
} catch {
|
|
69
|
+
// Cache write failure is non-critical
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Search FediDB for instances matching a query.
|
|
75
|
+
* Returns a flat array of { domain, software, description, mau, openRegistration }.
|
|
76
|
+
*
|
|
77
|
+
* Results are cached per normalized query for 24 hours.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} kvCollection - MongoDB ap_kv collection
|
|
80
|
+
* @param {string} query - Search term (e.g. "mast")
|
|
81
|
+
* @param {number} [limit=10] - Max results
|
|
82
|
+
* @returns {Promise<Array>}
|
|
83
|
+
*/
|
|
84
|
+
export async function searchInstances(kvCollection, query, limit = 10) {
|
|
85
|
+
const q = (query || "").trim().toLowerCase();
|
|
86
|
+
if (!q) return [];
|
|
87
|
+
|
|
88
|
+
const cacheKey = `fedidb:instances:${q}:${limit}`;
|
|
89
|
+
const cached = await getFromCache(kvCollection, cacheKey);
|
|
90
|
+
if (cached) return cached;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const url = `${API_BASE}/servers?q=${encodeURIComponent(q)}&limit=${limit}`;
|
|
94
|
+
const res = await fetchWithTimeout(url);
|
|
95
|
+
if (!res.ok) return [];
|
|
96
|
+
|
|
97
|
+
const json = await res.json();
|
|
98
|
+
const servers = json.data || [];
|
|
99
|
+
|
|
100
|
+
const results = servers.map((s) => ({
|
|
101
|
+
domain: s.domain,
|
|
102
|
+
software: s.software?.name || "Unknown",
|
|
103
|
+
description: s.description || "",
|
|
104
|
+
mau: s.stats?.monthly_active_users || 0,
|
|
105
|
+
userCount: s.stats?.user_count || 0,
|
|
106
|
+
openRegistration: s.open_registration || false,
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
await writeToCache(kvCollection, cacheKey, results);
|
|
110
|
+
return results;
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check if a remote instance supports unauthenticated public timeline access.
|
|
118
|
+
* Makes a lightweight HEAD-like request (limit=1) to the Mastodon public timeline API.
|
|
119
|
+
*
|
|
120
|
+
* Cached per domain for 24 hours.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} kvCollection - MongoDB ap_kv collection
|
|
123
|
+
* @param {string} domain - Instance hostname
|
|
124
|
+
* @returns {Promise<{ supported: boolean, error: string|null }>}
|
|
125
|
+
*/
|
|
126
|
+
export async function checkInstanceTimeline(kvCollection, domain) {
|
|
127
|
+
const cacheKey = `fedidb:timeline-check:${domain}`;
|
|
128
|
+
const cached = await getFromCache(kvCollection, cacheKey);
|
|
129
|
+
if (cached) return cached;
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const url = `https://${domain}/api/v1/timelines/public?local=true&limit=1`;
|
|
133
|
+
const res = await fetchWithTimeout(url);
|
|
134
|
+
|
|
135
|
+
let result;
|
|
136
|
+
if (res.ok) {
|
|
137
|
+
result = { supported: true, error: null };
|
|
138
|
+
} else {
|
|
139
|
+
let errorMsg = `HTTP ${res.status}`;
|
|
140
|
+
try {
|
|
141
|
+
const body = await res.json();
|
|
142
|
+
if (body.error) errorMsg = body.error;
|
|
143
|
+
} catch {
|
|
144
|
+
// Can't parse body
|
|
145
|
+
}
|
|
146
|
+
result = { supported: false, error: errorMsg };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await writeToCache(kvCollection, cacheKey, result);
|
|
150
|
+
return result;
|
|
151
|
+
} catch {
|
|
152
|
+
return { supported: false, error: "Connection failed" };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Fetch popular fediverse accounts from FediDB.
|
|
158
|
+
* Returns a flat array of { username, name, domain, handle, url, avatar, followers, bio }.
|
|
159
|
+
*
|
|
160
|
+
* Cached for 24 hours (single cache entry).
|
|
161
|
+
*
|
|
162
|
+
* @param {object} kvCollection - MongoDB ap_kv collection
|
|
163
|
+
* @param {number} [limit=50] - Max accounts to fetch
|
|
164
|
+
* @returns {Promise<Array>}
|
|
165
|
+
*/
|
|
166
|
+
export async function getPopularAccounts(kvCollection, limit = 50) {
|
|
167
|
+
const cacheKey = `fedidb:popular-accounts:${limit}`;
|
|
168
|
+
const cached = await getFromCache(kvCollection, cacheKey);
|
|
169
|
+
if (cached) return cached;
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const url = `${API_BASE}/popular-accounts?limit=${limit}`;
|
|
173
|
+
const res = await fetchWithTimeout(url);
|
|
174
|
+
if (!res.ok) return [];
|
|
175
|
+
|
|
176
|
+
const json = await res.json();
|
|
177
|
+
const accounts = json.data || [];
|
|
178
|
+
|
|
179
|
+
const results = accounts.map((a) => ({
|
|
180
|
+
username: a.username || "",
|
|
181
|
+
name: a.name || a.username || "",
|
|
182
|
+
domain: a.domain || "",
|
|
183
|
+
handle: `@${a.username}@${a.domain}`,
|
|
184
|
+
url: a.account_url || "",
|
|
185
|
+
avatar: a.avatar_url || "",
|
|
186
|
+
followers: a.followers_count || 0,
|
|
187
|
+
bio: (a.bio || "").replace(/<[^>]*>/g, "").slice(0, 120),
|
|
188
|
+
}));
|
|
189
|
+
|
|
190
|
+
await writeToCache(kvCollection, cacheKey, results);
|
|
191
|
+
return results;
|
|
192
|
+
} catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
package/locales/en.json
CHANGED
|
@@ -220,7 +220,8 @@
|
|
|
220
220
|
"label": "Look up a fediverse post or account",
|
|
221
221
|
"button": "Look up",
|
|
222
222
|
"notFoundTitle": "Not found",
|
|
223
|
-
"notFound": "Could not find this post or account. The URL may be invalid, the server may be unavailable, or the content may have been deleted."
|
|
223
|
+
"notFound": "Could not find this post or account. The URL may be invalid, the server may be unavailable, or the content may have been deleted.",
|
|
224
|
+
"followersLabel": "followers"
|
|
224
225
|
},
|
|
225
226
|
"linkPreview": {
|
|
226
227
|
"label": "Link preview"
|
|
@@ -235,7 +236,10 @@
|
|
|
235
236
|
"loadError": "Could not load timeline from this instance. It may be unavailable or not support the Mastodon API.",
|
|
236
237
|
"timeout": "Request timed out. The instance may be slow or unavailable.",
|
|
237
238
|
"noResults": "No posts found on this instance's public timeline.",
|
|
238
|
-
"invalidInstance": "Invalid instance hostname. Please enter a valid domain name."
|
|
239
|
+
"invalidInstance": "Invalid instance hostname. Please enter a valid domain name.",
|
|
240
|
+
"mauLabel": "MAU",
|
|
241
|
+
"timelineSupported": "Public timeline available",
|
|
242
|
+
"timelineUnsupported": "Public timeline not available"
|
|
239
243
|
},
|
|
240
244
|
"tagTimeline": {
|
|
241
245
|
"postsTagged": "%d posts",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.29",
|
|
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",
|
|
@@ -9,18 +9,62 @@
|
|
|
9
9
|
<p class="ap-explore-header__desc">{{ __("activitypub.reader.explore.description") }}</p>
|
|
10
10
|
</header>
|
|
11
11
|
|
|
12
|
-
{# Instance form #}
|
|
13
|
-
<form action="{{ mountPath }}/admin/reader/explore" method="get" class="ap-explore-form"
|
|
12
|
+
{# Instance form with autocomplete #}
|
|
13
|
+
<form action="{{ mountPath }}/admin/reader/explore" method="get" class="ap-explore-form"
|
|
14
|
+
x-data="apInstanceSearch('{{ mountPath }}')"
|
|
15
|
+
@submit="onSubmit">
|
|
14
16
|
<div class="ap-explore-form__row">
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
<div class="ap-explore-autocomplete">
|
|
18
|
+
<input
|
|
19
|
+
type="text"
|
|
20
|
+
name="instance"
|
|
21
|
+
value="{{ instance }}"
|
|
22
|
+
class="ap-explore-form__input"
|
|
23
|
+
placeholder="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
|
|
24
|
+
aria-label="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
|
|
25
|
+
autocomplete="off"
|
|
26
|
+
required
|
|
27
|
+
x-model="query"
|
|
28
|
+
@input.debounce.300ms="search()"
|
|
29
|
+
@keydown.arrow-down.prevent="highlightNext()"
|
|
30
|
+
@keydown.arrow-up.prevent="highlightPrev()"
|
|
31
|
+
@keydown.enter="selectHighlighted($event)"
|
|
32
|
+
@keydown.escape="close()"
|
|
33
|
+
@focus="showResults && suggestions.length > 0 ? showResults = true : null"
|
|
34
|
+
@click.away="close()"
|
|
35
|
+
x-ref="input">
|
|
36
|
+
|
|
37
|
+
{# Autocomplete dropdown #}
|
|
38
|
+
<div class="ap-explore-autocomplete__dropdown" x-show="showResults && suggestions.length > 0" x-cloak>
|
|
39
|
+
<template x-for="(item, index) in suggestions" :key="item.domain">
|
|
40
|
+
<button type="button"
|
|
41
|
+
class="ap-explore-autocomplete__item"
|
|
42
|
+
:class="{ 'ap-explore-autocomplete__item--highlighted': index === highlighted }"
|
|
43
|
+
@click="selectItem(item)"
|
|
44
|
+
@mouseenter="highlighted = index">
|
|
45
|
+
<span class="ap-explore-autocomplete__domain" x-text="item.domain"></span>
|
|
46
|
+
<span class="ap-explore-autocomplete__meta">
|
|
47
|
+
<span class="ap-explore-autocomplete__software" x-text="item.software"></span>
|
|
48
|
+
<template x-if="item.mau > 0">
|
|
49
|
+
<span class="ap-explore-autocomplete__mau" x-text="item.mau.toLocaleString() + ' {{ __("activitypub.reader.explore.mauLabel") }}'"></span>
|
|
50
|
+
</template>
|
|
51
|
+
</span>
|
|
52
|
+
<span class="ap-explore-autocomplete__status" x-show="item._timelineStatus !== undefined">
|
|
53
|
+
<template x-if="item._timelineStatus === 'checking'">
|
|
54
|
+
<span class="ap-explore-autocomplete__checking">⏳</span>
|
|
55
|
+
</template>
|
|
56
|
+
<template x-if="item._timelineStatus === true">
|
|
57
|
+
<span class="ap-explore-autocomplete__supported" title="{{ __('activitypub.reader.explore.timelineSupported') }}">✅</span>
|
|
58
|
+
</template>
|
|
59
|
+
<template x-if="item._timelineStatus === false">
|
|
60
|
+
<span class="ap-explore-autocomplete__unsupported" title="{{ __('activitypub.reader.explore.timelineUnsupported') }}">❌</span>
|
|
61
|
+
</template>
|
|
62
|
+
</span>
|
|
63
|
+
</button>
|
|
64
|
+
</template>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
24
68
|
<div class="ap-explore-form__scope">
|
|
25
69
|
<label class="ap-explore-form__scope-label">
|
|
26
70
|
<input type="radio" name="scope" value="local"
|
|
@@ -21,11 +21,44 @@
|
|
|
21
21
|
</div>
|
|
22
22
|
{% endif %}
|
|
23
23
|
|
|
24
|
-
{# Fediverse lookup #}
|
|
25
|
-
<form action="{{ mountPath }}/admin/reader/resolve" method="get" class="ap-lookup"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
{# Fediverse lookup with popular accounts autocomplete #}
|
|
25
|
+
<form action="{{ mountPath }}/admin/reader/resolve" method="get" class="ap-lookup"
|
|
26
|
+
x-data="apPopularAccounts('{{ mountPath }}')"
|
|
27
|
+
@submit="onSubmit">
|
|
28
|
+
<div class="ap-lookup-autocomplete">
|
|
29
|
+
<input type="text" name="q" class="ap-lookup__input"
|
|
30
|
+
placeholder="{{ __('activitypub.reader.resolve.placeholder') }}"
|
|
31
|
+
aria-label="{{ __('activitypub.reader.resolve.label') }}"
|
|
32
|
+
x-model="query"
|
|
33
|
+
@focus="loadAccounts()"
|
|
34
|
+
@input.debounce.200ms="filterAccounts()"
|
|
35
|
+
@keydown.arrow-down.prevent="highlightNext()"
|
|
36
|
+
@keydown.arrow-up.prevent="highlightPrev()"
|
|
37
|
+
@keydown.enter="selectHighlighted($event)"
|
|
38
|
+
@keydown.escape="close()"
|
|
39
|
+
@click.away="close()"
|
|
40
|
+
x-ref="input">
|
|
41
|
+
|
|
42
|
+
{# Popular accounts dropdown #}
|
|
43
|
+
<div class="ap-lookup-autocomplete__dropdown" x-show="showResults && suggestions.length > 0" x-cloak>
|
|
44
|
+
<template x-for="(item, index) in suggestions" :key="item.handle">
|
|
45
|
+
<button type="button"
|
|
46
|
+
class="ap-lookup-autocomplete__item"
|
|
47
|
+
:class="{ 'ap-lookup-autocomplete__item--highlighted': index === highlighted }"
|
|
48
|
+
@click="selectItem(item)"
|
|
49
|
+
@mouseenter="highlighted = index">
|
|
50
|
+
<img :src="item.avatar" :alt="item.name" class="ap-lookup-autocomplete__avatar"
|
|
51
|
+
onerror="this.style.display='none'">
|
|
52
|
+
<span class="ap-lookup-autocomplete__info">
|
|
53
|
+
<span class="ap-lookup-autocomplete__name" x-text="item.name"></span>
|
|
54
|
+
<span class="ap-lookup-autocomplete__handle" x-text="item.handle"></span>
|
|
55
|
+
</span>
|
|
56
|
+
<span class="ap-lookup-autocomplete__followers"
|
|
57
|
+
x-text="item.followers.toLocaleString() + ' {{ __("activitypub.reader.resolve.followersLabel") }}'"></span>
|
|
58
|
+
</button>
|
|
59
|
+
</template>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
29
62
|
<button type="submit" class="ap-lookup__btn">{{ __("activitypub.reader.resolve.button") }}</button>
|
|
30
63
|
</form>
|
|
31
64
|
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
{% block content %}
|
|
4
4
|
{# Infinite scroll component — must load before Alpine to register via alpine:init #}
|
|
5
5
|
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-infinite-scroll.js"></script>
|
|
6
|
+
{# Autocomplete components for explore + popular accounts #}
|
|
7
|
+
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-autocomplete.js"></script>
|
|
6
8
|
|
|
7
9
|
{# Alpine.js for client-side reactivity (CW toggles, interaction buttons, infinite scroll) #}
|
|
8
10
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
|