mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
feat: add FediDB-powered autocomplete for explore and reader lookup
- Add FediDB API client (lib/fedidb.js) with MongoDB caching (24h TTL) for instance search, timeline support checks, and popular accounts - Explore page: instance input now shows autocomplete suggestions from FediDB with software type, MAU count, and timeline support indicator (checkmark/cross) via background pre-check - Reader page: @handle lookup input now shows popular fediverse accounts from FediDB with avatar, name, handle, and follower count - Three new API endpoints: /api/instances, /api/instance-check, /api/popular-accounts - Alpine.js components for both autocomplete UIs with keyboard navigation
This commit is contained in:
214
assets/reader-autocomplete.js
Normal file
214
assets/reader-autocomplete.js
Normal file
@@ -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();
|
||||
},
|
||||
}));
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
11
index.js
11
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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
195
lib/fedidb.js
Normal file
195
lib/fedidb.js
Normal file
@@ -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<Response>}
|
||||
*/
|
||||
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<object|null>} 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<Array>}
|
||||
*/
|
||||
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<Array>}
|
||||
*/
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -9,18 +9,62 @@
|
||||
<p class="ap-explore-header__desc">{{ __("activitypub.reader.explore.description") }}</p>
|
||||
</header>
|
||||
|
||||
{# Instance form #}
|
||||
<form action="{{ mountPath }}/admin/reader/explore" method="get" class="ap-explore-form">
|
||||
{# Instance form with autocomplete #}
|
||||
<form action="{{ mountPath }}/admin/reader/explore" method="get" class="ap-explore-form"
|
||||
x-data="apInstanceSearch('{{ mountPath }}')"
|
||||
@submit="onSubmit">
|
||||
<div class="ap-explore-form__row">
|
||||
<input
|
||||
type="text"
|
||||
name="instance"
|
||||
value="{{ instance }}"
|
||||
class="ap-explore-form__input"
|
||||
placeholder="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
|
||||
aria-label="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
|
||||
autocomplete="off"
|
||||
required>
|
||||
<div class="ap-explore-autocomplete">
|
||||
<input
|
||||
type="text"
|
||||
name="instance"
|
||||
value="{{ instance }}"
|
||||
class="ap-explore-form__input"
|
||||
placeholder="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
|
||||
aria-label="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
|
||||
autocomplete="off"
|
||||
required
|
||||
x-model="query"
|
||||
@input.debounce.300ms="search()"
|
||||
@keydown.arrow-down.prevent="highlightNext()"
|
||||
@keydown.arrow-up.prevent="highlightPrev()"
|
||||
@keydown.enter="selectHighlighted($event)"
|
||||
@keydown.escape="close()"
|
||||
@focus="showResults && suggestions.length > 0 ? showResults = true : null"
|
||||
@click.away="close()"
|
||||
x-ref="input">
|
||||
|
||||
{# Autocomplete dropdown #}
|
||||
<div class="ap-explore-autocomplete__dropdown" x-show="showResults && suggestions.length > 0" x-cloak>
|
||||
<template x-for="(item, index) in suggestions" :key="item.domain">
|
||||
<button type="button"
|
||||
class="ap-explore-autocomplete__item"
|
||||
:class="{ 'ap-explore-autocomplete__item--highlighted': index === highlighted }"
|
||||
@click="selectItem(item)"
|
||||
@mouseenter="highlighted = index">
|
||||
<span class="ap-explore-autocomplete__domain" x-text="item.domain"></span>
|
||||
<span class="ap-explore-autocomplete__meta">
|
||||
<span class="ap-explore-autocomplete__software" x-text="item.software"></span>
|
||||
<template x-if="item.mau > 0">
|
||||
<span class="ap-explore-autocomplete__mau" x-text="item.mau.toLocaleString() + ' {{ __("activitypub.reader.explore.mauLabel") }}'"></span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="ap-explore-autocomplete__status" x-show="item._timelineStatus !== undefined">
|
||||
<template x-if="item._timelineStatus === 'checking'">
|
||||
<span class="ap-explore-autocomplete__checking">⏳</span>
|
||||
</template>
|
||||
<template x-if="item._timelineStatus === true">
|
||||
<span class="ap-explore-autocomplete__supported" title="{{ __('activitypub.reader.explore.timelineSupported') }}">✅</span>
|
||||
</template>
|
||||
<template x-if="item._timelineStatus === false">
|
||||
<span class="ap-explore-autocomplete__unsupported" title="{{ __('activitypub.reader.explore.timelineUnsupported') }}">❌</span>
|
||||
</template>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ap-explore-form__scope">
|
||||
<label class="ap-explore-form__scope-label">
|
||||
<input type="radio" name="scope" value="local"
|
||||
|
||||
@@ -21,11 +21,44 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Fediverse lookup #}
|
||||
<form action="{{ mountPath }}/admin/reader/resolve" method="get" class="ap-lookup">
|
||||
<input type="text" name="q" class="ap-lookup__input"
|
||||
placeholder="{{ __('activitypub.reader.resolve.placeholder') }}"
|
||||
aria-label="{{ __('activitypub.reader.resolve.label') }}">
|
||||
{# Fediverse lookup with popular accounts autocomplete #}
|
||||
<form action="{{ mountPath }}/admin/reader/resolve" method="get" class="ap-lookup"
|
||||
x-data="apPopularAccounts('{{ mountPath }}')"
|
||||
@submit="onSubmit">
|
||||
<div class="ap-lookup-autocomplete">
|
||||
<input type="text" name="q" class="ap-lookup__input"
|
||||
placeholder="{{ __('activitypub.reader.resolve.placeholder') }}"
|
||||
aria-label="{{ __('activitypub.reader.resolve.label') }}"
|
||||
x-model="query"
|
||||
@focus="loadAccounts()"
|
||||
@input.debounce.200ms="filterAccounts()"
|
||||
@keydown.arrow-down.prevent="highlightNext()"
|
||||
@keydown.arrow-up.prevent="highlightPrev()"
|
||||
@keydown.enter="selectHighlighted($event)"
|
||||
@keydown.escape="close()"
|
||||
@click.away="close()"
|
||||
x-ref="input">
|
||||
|
||||
{# Popular accounts dropdown #}
|
||||
<div class="ap-lookup-autocomplete__dropdown" x-show="showResults && suggestions.length > 0" x-cloak>
|
||||
<template x-for="(item, index) in suggestions" :key="item.handle">
|
||||
<button type="button"
|
||||
class="ap-lookup-autocomplete__item"
|
||||
:class="{ 'ap-lookup-autocomplete__item--highlighted': index === highlighted }"
|
||||
@click="selectItem(item)"
|
||||
@mouseenter="highlighted = index">
|
||||
<img :src="item.avatar" :alt="item.name" class="ap-lookup-autocomplete__avatar"
|
||||
onerror="this.style.display='none'">
|
||||
<span class="ap-lookup-autocomplete__info">
|
||||
<span class="ap-lookup-autocomplete__name" x-text="item.name"></span>
|
||||
<span class="ap-lookup-autocomplete__handle" x-text="item.handle"></span>
|
||||
</span>
|
||||
<span class="ap-lookup-autocomplete__followers"
|
||||
x-text="item.followers.toLocaleString() + ' {{ __("activitypub.reader.resolve.followersLabel") }}'"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="ap-lookup__btn">{{ __("activitypub.reader.resolve.button") }}</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
{% block content %}
|
||||
{# Infinite scroll component — must load before Alpine to register via alpine:init #}
|
||||
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-infinite-scroll.js"></script>
|
||||
{# Autocomplete components for explore + popular accounts #}
|
||||
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-autocomplete.js"></script>
|
||||
|
||||
{# Alpine.js for client-side reactivity (CW toggles, interaction buttons, infinite scroll) #}
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user