@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.
@@ -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 { exploreController, exploreApiController } from "./lib/controllers/explore.js";
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.28",
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
- <input
16
- type="text"
17
- name="instance"
18
- value="{{ instance }}"
19
- class="ap-explore-form__input"
20
- placeholder="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
21
- aria-label="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
22
- autocomplete="off"
23
- required>
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
- <input type="text" name="q" class="ap-lookup__input"
27
- placeholder="{{ __('activitypub.reader.resolve.placeholder') }}"
28
- aria-label="{{ __('activitypub.reader.resolve.label') }}">
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>