diff --git a/assets/reader-autocomplete.js b/assets/reader-autocomplete.js new file mode 100644 index 0000000..91df5e3 --- /dev/null +++ b/assets/reader-autocomplete.js @@ -0,0 +1,214 @@ +/** + * Autocomplete — Alpine.js components for FediDB-powered search suggestions. + * Registers `apInstanceSearch` for the explore page instance input. + */ + +document.addEventListener("alpine:init", () => { + // eslint-disable-next-line no-undef + Alpine.data("apInstanceSearch", (mountPath) => ({ + query: "", + suggestions: [], + showResults: false, + highlighted: -1, + abortController: null, + + init() { + // Pick up server-rendered value (when returning to page with instance already loaded) + const input = this.$refs.input; + if (input && input.getAttribute("value")) { + this.query = input.getAttribute("value"); + } + }, + + // Debounced search triggered by x-on:input + async search() { + const q = (this.query || "").trim(); + if (q.length < 2) { + this.suggestions = []; + this.showResults = false; + return; + } + + // Cancel any in-flight request + if (this.abortController) { + this.abortController.abort(); + } + this.abortController = new AbortController(); + + try { + const res = await fetch( + `${mountPath}/admin/reader/api/instances?q=${encodeURIComponent(q)}`, + { signal: this.abortController.signal } + ); + if (!res.ok) return; + + const data = await res.json(); + // Mark _timelineStatus as undefined (not yet checked) + this.suggestions = data.map((item) => ({ + ...item, + _timelineStatus: undefined, + })); + this.highlighted = -1; + this.showResults = this.suggestions.length > 0; + + // Fire timeline support checks in parallel (non-blocking) + this.checkTimelineSupport(); + } catch (err) { + if (err.name !== "AbortError") { + this.suggestions = []; + this.showResults = false; + } + } + }, + + // Check timeline support for each suggestion (background, non-blocking) + async checkTimelineSupport() { + const items = [...this.suggestions]; + for (const item of items) { + // Only check if still in the current suggestions list + const match = this.suggestions.find((s) => s.domain === item.domain); + if (!match) continue; + + match._timelineStatus = "checking"; + + try { + const res = await fetch( + `${mountPath}/admin/reader/api/instance-check?domain=${encodeURIComponent(item.domain)}` + ); + if (!res.ok) continue; + + const data = await res.json(); + // Update the item in the current suggestions (if still present) + const current = this.suggestions.find((s) => s.domain === item.domain); + if (current) { + current._timelineStatus = data.supported; + } + } catch { + const current = this.suggestions.find((s) => s.domain === item.domain); + if (current) { + current._timelineStatus = false; + } + } + } + }, + + selectItem(item) { + this.query = item.domain; + this.showResults = false; + this.suggestions = []; + this.$refs.input.focus(); + }, + + close() { + this.showResults = false; + this.highlighted = -1; + }, + + highlightNext() { + if (!this.showResults || this.suggestions.length === 0) return; + this.highlighted = (this.highlighted + 1) % this.suggestions.length; + }, + + highlightPrev() { + if (!this.showResults || this.suggestions.length === 0) return; + this.highlighted = + this.highlighted <= 0 + ? this.suggestions.length - 1 + : this.highlighted - 1; + }, + + selectHighlighted(event) { + if (this.showResults && this.highlighted >= 0 && this.suggestions[this.highlighted]) { + event.preventDefault(); + this.selectItem(this.suggestions[this.highlighted]); + } + // Otherwise let the form submit naturally + }, + + onSubmit() { + this.close(); + }, + })); + + // eslint-disable-next-line no-undef + Alpine.data("apPopularAccounts", (mountPath) => ({ + query: "", + suggestions: [], + allAccounts: [], + showResults: false, + highlighted: -1, + loaded: false, + + // Load popular accounts on first focus (lazy) + async loadAccounts() { + if (this.loaded) return; + this.loaded = true; + + try { + const res = await fetch(`${mountPath}/admin/reader/api/popular-accounts`); + if (!res.ok) return; + this.allAccounts = await res.json(); + } catch { + // Non-critical + } + }, + + // Filter locally from preloaded list + filterAccounts() { + const q = (this.query || "").trim().toLowerCase(); + if (q.length < 1 || this.allAccounts.length === 0) { + this.suggestions = []; + this.showResults = false; + return; + } + + this.suggestions = this.allAccounts + .filter( + (a) => + a.username.toLowerCase().includes(q) || + a.name.toLowerCase().includes(q) || + a.domain.toLowerCase().includes(q) || + a.handle.toLowerCase().includes(q) + ) + .slice(0, 8); + this.highlighted = -1; + this.showResults = this.suggestions.length > 0; + }, + + selectItem(item) { + this.query = item.handle; + this.showResults = false; + this.suggestions = []; + this.$refs.input.focus(); + }, + + close() { + this.showResults = false; + this.highlighted = -1; + }, + + highlightNext() { + if (!this.showResults || this.suggestions.length === 0) return; + this.highlighted = (this.highlighted + 1) % this.suggestions.length; + }, + + highlightPrev() { + if (!this.showResults || this.suggestions.length === 0) return; + this.highlighted = + this.highlighted <= 0 + ? this.suggestions.length - 1 + : this.highlighted - 1; + }, + + selectHighlighted(event) { + if (this.showResults && this.highlighted >= 0 && this.suggestions[this.highlighted]) { + event.preventDefault(); + this.selectItem(this.suggestions[this.highlighted]); + } + }, + + onSubmit() { + this.close(); + }, + })); +}); diff --git a/assets/reader.css b/assets/reader.css index f3cfb10..b275aac 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -1838,6 +1838,162 @@ } } +/* ---------- Autocomplete dropdown ---------- */ + +.ap-explore-autocomplete { + flex: 1; + min-width: 0; + position: relative; +} + +.ap-explore-autocomplete__dropdown { + background: var(--color-background); + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + left: 0; + max-height: 320px; + overflow-y: auto; + position: absolute; + right: 0; + top: 100%; + z-index: 100; +} + +.ap-explore-autocomplete__item { + align-items: center; + background: none; + border: none; + color: var(--color-on-background); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: var(--font-size-s); + gap: var(--space-s); + padding: var(--space-s) var(--space-m); + text-align: left; + width: 100%; +} + +.ap-explore-autocomplete__item:hover, +.ap-explore-autocomplete__item--highlighted { + background: var(--color-offset); +} + +.ap-explore-autocomplete__domain { + flex-shrink: 0; + font-weight: 600; +} + +.ap-explore-autocomplete__meta { + color: var(--color-on-offset); + display: flex; + flex: 1; + gap: var(--space-xs); + min-width: 0; +} + +.ap-explore-autocomplete__software { + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + border-radius: var(--border-radius-small); + font-size: var(--font-size-xs); + padding: 1px 6px; + white-space: nowrap; +} + +.ap-explore-autocomplete__mau { + font-size: var(--font-size-xs); + white-space: nowrap; +} + +.ap-explore-autocomplete__status { + flex-shrink: 0; + font-size: var(--font-size-s); +} + +.ap-explore-autocomplete__checking { + opacity: 0.5; +} + +/* ---------- Popular accounts autocomplete ---------- */ + +.ap-lookup-autocomplete { + flex: 1; + min-width: 0; + position: relative; +} + +.ap-lookup-autocomplete__dropdown { + background: var(--color-background); + border: var(--border-width-thin) solid var(--color-outline); + border-radius: var(--border-radius-small); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + left: 0; + max-height: 320px; + overflow-y: auto; + position: absolute; + right: 0; + top: 100%; + z-index: 100; +} + +.ap-lookup-autocomplete__item { + align-items: center; + background: none; + border: none; + color: var(--color-on-background); + cursor: pointer; + display: flex; + font-family: inherit; + font-size: var(--font-size-s); + gap: var(--space-s); + padding: var(--space-s) var(--space-m); + text-align: left; + width: 100%; +} + +.ap-lookup-autocomplete__item:hover, +.ap-lookup-autocomplete__item--highlighted { + background: var(--color-offset); +} + +.ap-lookup-autocomplete__avatar { + border-radius: 50%; + flex-shrink: 0; + height: 28px; + object-fit: cover; + width: 28px; +} + +.ap-lookup-autocomplete__info { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.ap-lookup-autocomplete__name { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-lookup-autocomplete__handle { + color: var(--color-on-offset); + font-size: var(--font-size-xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-lookup-autocomplete__followers { + color: var(--color-on-offset); + flex-shrink: 0; + font-size: var(--font-size-xs); + white-space: nowrap; +} + /* Replies — indented from the other side */ .ap-post-detail__replies { margin-left: var(--space-l); diff --git a/index.js b/index.js index d171e88..c8b5a7c 100644 --- a/index.js +++ b/index.js @@ -61,7 +61,13 @@ import { import { resolveController } from "./lib/controllers/resolve.js"; import { tagTimelineController } from "./lib/controllers/tag-timeline.js"; import { apiTimelineController } from "./lib/controllers/api-timeline.js"; -import { exploreController, exploreApiController } from "./lib/controllers/explore.js"; +import { + exploreController, + exploreApiController, + instanceSearchApiController, + instanceCheckApiController, + popularAccountsApiController, +} from "./lib/controllers/explore.js"; import { followTagController, unfollowTagController } from "./lib/controllers/follow-tag.js"; import { publicProfileController } from "./lib/controllers/public-profile.js"; import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.js"; @@ -227,6 +233,9 @@ export default class ActivityPubEndpoint { router.get("/admin/reader/api/timeline", apiTimelineController(mp)); router.get("/admin/reader/explore", exploreController(mp)); router.get("/admin/reader/api/explore", exploreApiController(mp)); + router.get("/admin/reader/api/instances", instanceSearchApiController(mp)); + router.get("/admin/reader/api/instance-check", instanceCheckApiController(mp)); + router.get("/admin/reader/api/popular-accounts", popularAccountsApiController(mp)); router.post("/admin/reader/follow-tag", followTagController(mp)); router.post("/admin/reader/unfollow-tag", unfollowTagController(mp)); router.get("/admin/reader/notifications", notificationsController(mp)); diff --git a/lib/controllers/explore.js b/lib/controllers/explore.js index a0a164f..76eab30 100644 --- a/lib/controllers/explore.js +++ b/lib/controllers/explore.js @@ -7,6 +7,7 @@ import sanitizeHtml from "sanitize-html"; import { sanitizeContent } from "../timeline-store.js"; +import { searchInstances, checkInstanceTimeline, getPopularAccounts } from "../fedidb.js"; const FETCH_TIMEOUT_MS = 10_000; const MAX_RESULTS = 20; @@ -291,3 +292,73 @@ export function exploreApiController(mountPath) { } }; } + +/** + * AJAX API endpoint for instance autocomplete. + * Returns JSON array of matching instances from FediDB. + */ +export function instanceSearchApiController(mountPath) { + return async (request, response, next) => { + try { + const q = (request.query.q || "").trim(); + if (!q || q.length < 2) { + return response.json([]); + } + + const { application } = request.app.locals; + const kvCollection = application?.collections?.get("ap_kv") || null; + + const results = await searchInstances(kvCollection, q, 8); + response.json(results); + } catch (error) { + next(error); + } + }; +} + +/** + * AJAX API endpoint to check if an instance supports public timeline exploration. + * Returns JSON { supported: boolean, error: string|null }. + */ +export function instanceCheckApiController(mountPath) { + return async (request, response, next) => { + try { + const domain = (request.query.domain || "").trim().toLowerCase(); + if (!domain) { + return response.status(400).json({ supported: false, error: "Missing domain" }); + } + + // Validate domain to prevent SSRF + const validated = validateInstance(domain); + if (!validated) { + return response.status(400).json({ supported: false, error: "Invalid domain" }); + } + + const { application } = request.app.locals; + const kvCollection = application?.collections?.get("ap_kv") || null; + + const result = await checkInstanceTimeline(kvCollection, validated); + response.json(result); + } catch (error) { + next(error); + } + }; +} + +/** + * AJAX API endpoint for popular fediverse accounts. + * Returns the full cached list; client-side filtering via Alpine.js. + */ +export function popularAccountsApiController(mountPath) { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + const kvCollection = application?.collections?.get("ap_kv") || null; + + const accounts = await getPopularAccounts(kvCollection, 50); + response.json(accounts); + } catch (error) { + next(error); + } + }; +} diff --git a/lib/fedidb.js b/lib/fedidb.js new file mode 100644 index 0000000..83f69b9 --- /dev/null +++ b/lib/fedidb.js @@ -0,0 +1,195 @@ +/** + * FediDB API client with MongoDB caching. + * + * Wraps https://api.fedidb.org/v1/ endpoints: + * - /servers?q=... — search known fediverse instances + * - /popular-accounts — top accounts by follower count + * + * Responses are cached in ap_kv to avoid hitting the API on every keystroke. + * Cache TTL: 24 hours for both datasets. + */ + +const API_BASE = "https://api.fedidb.org/v1"; +const FETCH_TIMEOUT_MS = 8_000; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +/** + * Fetch with timeout helper. + * @param {string} url + * @returns {Promise} + */ +async function fetchWithTimeout(url) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + return res; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Get cached data from ap_kv, or null if expired/missing. + * @param {object} kvCollection - MongoDB ap_kv collection + * @param {string} cacheKey - Key to look up + * @returns {Promise} Cached data or null + */ +async function getFromCache(kvCollection, cacheKey) { + if (!kvCollection) return null; + try { + const doc = await kvCollection.findOne({ _id: cacheKey }); + if (!doc?.value?.data) return null; + const age = Date.now() - (doc.value.cachedAt || 0); + if (age > CACHE_TTL_MS) return null; + return doc.value.data; + } catch { + return null; + } +} + +/** + * Write data to ap_kv cache. + * @param {object} kvCollection - MongoDB ap_kv collection + * @param {string} cacheKey - Key to store under + * @param {object} data - Data to cache + */ +async function writeToCache(kvCollection, cacheKey, data) { + if (!kvCollection) return; + try { + await kvCollection.updateOne( + { _id: cacheKey }, + { $set: { value: { data, cachedAt: Date.now() } } }, + { upsert: true } + ); + } catch { + // Cache write failure is non-critical + } +} + +/** + * Search FediDB for instances matching a query. + * Returns a flat array of { domain, software, description, mau, openRegistration }. + * + * Results are cached per normalized query for 24 hours. + * + * @param {object} kvCollection - MongoDB ap_kv collection + * @param {string} query - Search term (e.g. "mast") + * @param {number} [limit=10] - Max results + * @returns {Promise} + */ +export async function searchInstances(kvCollection, query, limit = 10) { + const q = (query || "").trim().toLowerCase(); + if (!q) return []; + + const cacheKey = `fedidb:instances:${q}:${limit}`; + const cached = await getFromCache(kvCollection, cacheKey); + if (cached) return cached; + + try { + const url = `${API_BASE}/servers?q=${encodeURIComponent(q)}&limit=${limit}`; + const res = await fetchWithTimeout(url); + if (!res.ok) return []; + + const json = await res.json(); + const servers = json.data || []; + + const results = servers.map((s) => ({ + domain: s.domain, + software: s.software?.name || "Unknown", + description: s.description || "", + mau: s.stats?.monthly_active_users || 0, + userCount: s.stats?.user_count || 0, + openRegistration: s.open_registration || false, + })); + + await writeToCache(kvCollection, cacheKey, results); + return results; + } catch { + return []; + } +} + +/** + * Check if a remote instance supports unauthenticated public timeline access. + * Makes a lightweight HEAD-like request (limit=1) to the Mastodon public timeline API. + * + * Cached per domain for 24 hours. + * + * @param {object} kvCollection - MongoDB ap_kv collection + * @param {string} domain - Instance hostname + * @returns {Promise<{ supported: boolean, error: string|null }>} + */ +export async function checkInstanceTimeline(kvCollection, domain) { + const cacheKey = `fedidb:timeline-check:${domain}`; + const cached = await getFromCache(kvCollection, cacheKey); + if (cached) return cached; + + try { + const url = `https://${domain}/api/v1/timelines/public?local=true&limit=1`; + const res = await fetchWithTimeout(url); + + let result; + if (res.ok) { + result = { supported: true, error: null }; + } else { + let errorMsg = `HTTP ${res.status}`; + try { + const body = await res.json(); + if (body.error) errorMsg = body.error; + } catch { + // Can't parse body + } + result = { supported: false, error: errorMsg }; + } + + await writeToCache(kvCollection, cacheKey, result); + return result; + } catch { + return { supported: false, error: "Connection failed" }; + } +} + +/** + * Fetch popular fediverse accounts from FediDB. + * Returns a flat array of { username, name, domain, handle, url, avatar, followers, bio }. + * + * Cached for 24 hours (single cache entry). + * + * @param {object} kvCollection - MongoDB ap_kv collection + * @param {number} [limit=50] - Max accounts to fetch + * @returns {Promise} + */ +export async function getPopularAccounts(kvCollection, limit = 50) { + const cacheKey = `fedidb:popular-accounts:${limit}`; + const cached = await getFromCache(kvCollection, cacheKey); + if (cached) return cached; + + try { + const url = `${API_BASE}/popular-accounts?limit=${limit}`; + const res = await fetchWithTimeout(url); + if (!res.ok) return []; + + const json = await res.json(); + const accounts = json.data || []; + + const results = accounts.map((a) => ({ + username: a.username || "", + name: a.name || a.username || "", + domain: a.domain || "", + handle: `@${a.username}@${a.domain}`, + url: a.account_url || "", + avatar: a.avatar_url || "", + followers: a.followers_count || 0, + bio: (a.bio || "").replace(/<[^>]*>/g, "").slice(0, 120), + })); + + await writeToCache(kvCollection, cacheKey, results); + return results; + } catch { + return []; + } +} diff --git a/locales/en.json b/locales/en.json index c133460..9e725c4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -220,7 +220,8 @@ "label": "Look up a fediverse post or account", "button": "Look up", "notFoundTitle": "Not found", - "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." + "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.", + "followersLabel": "followers" }, "linkPreview": { "label": "Link preview" @@ -235,7 +236,10 @@ "loadError": "Could not load timeline from this instance. It may be unavailable or not support the Mastodon API.", "timeout": "Request timed out. The instance may be slow or unavailable.", "noResults": "No posts found on this instance's public timeline.", - "invalidInstance": "Invalid instance hostname. Please enter a valid domain name." + "invalidInstance": "Invalid instance hostname. Please enter a valid domain name.", + "mauLabel": "MAU", + "timelineSupported": "Public timeline available", + "timelineUnsupported": "Public timeline not available" }, "tagTimeline": { "postsTagged": "%d posts", diff --git a/views/activitypub-explore.njk b/views/activitypub-explore.njk index 2852711..6e29664 100644 --- a/views/activitypub-explore.njk +++ b/views/activitypub-explore.njk @@ -9,18 +9,62 @@

{{ __("activitypub.reader.explore.description") }}

- {# Instance form #} -
+ {# Instance form with autocomplete #} +
- +
+ + + {# Autocomplete dropdown #} +
+ +
+
+