@rmdes/indiekit-endpoint-activitypub 2.0.27 → 2.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
+ }
@@ -28,6 +28,7 @@ import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline
28
28
  import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
29
29
  import { addNotification } from "./storage/notifications.js";
30
30
  import { fetchAndStorePreviews } from "./og-unfurl.js";
31
+ import { getFollowedTags } from "./storage/followed-tags.js";
31
32
 
32
33
  /**
33
34
  * Register all inbox listeners on a federation's inbox chain.
@@ -492,6 +493,32 @@ export function registerInboxListeners(inboxChain, options) {
492
493
  // Log extraction errors but don't fail the entire handler
493
494
  console.error("Failed to store timeline item:", error);
494
495
  }
496
+ } else if (collections.ap_followed_tags) {
497
+ // Not a followed account — check if the post's hashtags match any followed tags
498
+ // so tagged posts from across the fediverse appear in the timeline
499
+ try {
500
+ const objectTags = Array.isArray(object.tag) ? object.tag : (object.tag ? [object.tag] : []);
501
+ const postHashtags = objectTags
502
+ .filter((t) => t.type === "Hashtag" && t.name)
503
+ .map((t) => t.name.toString().replace(/^#/, "").toLowerCase());
504
+
505
+ if (postHashtags.length > 0) {
506
+ const followedTags = await getFollowedTags(collections);
507
+ const followedSet = new Set(followedTags.map((t) => t.toLowerCase()));
508
+ const hasMatchingTag = postHashtags.some((tag) => followedSet.has(tag));
509
+
510
+ if (hasMatchingTag) {
511
+ const timelineItem = await extractObjectData(object, {
512
+ actorFallback: actorObj,
513
+ documentLoader: authLoader,
514
+ });
515
+ await addTimelineItem(collections, timelineItem);
516
+ }
517
+ }
518
+ } catch (error) {
519
+ // Non-critical — don't fail the handler
520
+ console.error("[inbox] Followed tag check failed:", error.message);
521
+ }
495
522
  }
496
523
 
497
524
  })
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Migration: separate-mentions
3
+ *
4
+ * Moves @-prefixed entries from category[] to a new mentions[] array in all
5
+ * ap_timeline documents. Tracked in ap_kv for idempotency.
6
+ *
7
+ * Before: category: ["@user@instance", "hashtag", "@another@host"]
8
+ * After: category: ["hashtag"]
9
+ * mentions: [{ name: "user@instance", url: "" }, { name: "another@host", url: "" }]
10
+ *
11
+ * Note: URLs are empty for legacy items since we can't reconstruct them.
12
+ * New items will have URLs populated by the fixed extractObjectData() (Task 1).
13
+ */
14
+
15
+ const MIGRATION_KEY = "migration:separate-mentions";
16
+
17
+ /**
18
+ * Run the separate-mentions migration (idempotent)
19
+ * @param {object} collections - MongoDB collections
20
+ * @returns {Promise<{ skipped: boolean, updated: number }>}
21
+ */
22
+ export async function runSeparateMentionsMigration(collections) {
23
+ const { ap_kv, ap_timeline } = collections;
24
+
25
+ // Check if already completed
26
+ const state = await ap_kv.findOne({ _id: MIGRATION_KEY });
27
+ if (state?.value?.completed) {
28
+ return { skipped: true, updated: 0 };
29
+ }
30
+
31
+ // Find all documents where category[] contains @-prefixed entries
32
+ const docs = await ap_timeline
33
+ .find({ category: { $regex: /^@/ } })
34
+ .toArray();
35
+
36
+ if (docs.length === 0) {
37
+ // No docs to migrate — mark complete immediately
38
+ await ap_kv.updateOne(
39
+ { _id: MIGRATION_KEY },
40
+ { $set: { value: { completed: true, date: new Date().toISOString(), updated: 0 } } },
41
+ { upsert: true }
42
+ );
43
+ return { skipped: false, updated: 0 };
44
+ }
45
+
46
+ // Build bulk operations
47
+ const ops = docs.map((doc) => {
48
+ const mentions = (doc.mentions || []).slice(); // preserve any existing mentions
49
+ const newCategory = [];
50
+
51
+ for (const entry of doc.category || []) {
52
+ if (typeof entry === "string" && entry.startsWith("@")) {
53
+ // Move to mentions[] — strip leading @ to match timeline-store convention
54
+ const strippedName = entry.slice(1);
55
+ const alreadyPresent = mentions.some((m) => m.name === strippedName);
56
+ if (!alreadyPresent) {
57
+ mentions.push({ name: strippedName, url: "" });
58
+ }
59
+ } else {
60
+ newCategory.push(entry);
61
+ }
62
+ }
63
+
64
+ return {
65
+ updateOne: {
66
+ filter: { _id: doc._id },
67
+ update: {
68
+ $set: {
69
+ category: newCategory,
70
+ mentions
71
+ }
72
+ }
73
+ }
74
+ };
75
+ });
76
+
77
+ const result = await ap_timeline.bulkWrite(ops, { ordered: false });
78
+ const updated = result.modifiedCount || 0;
79
+
80
+ // Mark migration complete
81
+ await ap_kv.updateOne(
82
+ { _id: MIGRATION_KEY },
83
+ { $set: { value: { completed: true, date: new Date().toISOString(), updated } } },
84
+ { upsert: true }
85
+ );
86
+
87
+ return { skipped: false, updated };
88
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Followed hashtag storage operations
3
+ * @module storage/followed-tags
4
+ */
5
+
6
+ /**
7
+ * Get all followed hashtags
8
+ * @param {object} collections - MongoDB collections
9
+ * @returns {Promise<string[]>} Array of tag strings (lowercase)
10
+ */
11
+ export async function getFollowedTags(collections) {
12
+ const { ap_followed_tags } = collections;
13
+ if (!ap_followed_tags) return [];
14
+ const docs = await ap_followed_tags.find({}).sort({ followedAt: -1 }).toArray();
15
+ return docs.map((d) => d.tag);
16
+ }
17
+
18
+ /**
19
+ * Follow a hashtag
20
+ * @param {object} collections - MongoDB collections
21
+ * @param {string} tag - Hashtag string (without # prefix)
22
+ * @returns {Promise<boolean>} true if newly added, false if already following
23
+ */
24
+ export async function followTag(collections, tag) {
25
+ const { ap_followed_tags } = collections;
26
+ const normalizedTag = tag.toLowerCase().trim().replace(/^#/, "");
27
+ if (!normalizedTag) return false;
28
+
29
+ const result = await ap_followed_tags.updateOne(
30
+ { tag: normalizedTag },
31
+ { $setOnInsert: { tag: normalizedTag, followedAt: new Date().toISOString() } },
32
+ { upsert: true }
33
+ );
34
+
35
+ return result.upsertedCount > 0;
36
+ }
37
+
38
+ /**
39
+ * Unfollow a hashtag
40
+ * @param {object} collections - MongoDB collections
41
+ * @param {string} tag - Hashtag string (without # prefix)
42
+ * @returns {Promise<boolean>} true if removed, false if not found
43
+ */
44
+ export async function unfollowTag(collections, tag) {
45
+ const { ap_followed_tags } = collections;
46
+ const normalizedTag = tag.toLowerCase().trim().replace(/^#/, "");
47
+ if (!normalizedTag) return false;
48
+
49
+ const result = await ap_followed_tags.deleteOne({ tag: normalizedTag });
50
+ return result.deletedCount > 0;
51
+ }
52
+
53
+ /**
54
+ * Check if a specific hashtag is followed
55
+ * @param {object} collections - MongoDB collections
56
+ * @param {string} tag - Hashtag string (without # prefix)
57
+ * @returns {Promise<boolean>}
58
+ */
59
+ export async function isTagFollowed(collections, tag) {
60
+ const { ap_followed_tags } = collections;
61
+ if (!ap_followed_tags) return false;
62
+ const normalizedTag = tag.toLowerCase().trim().replace(/^#/, "");
63
+ const doc = await ap_followed_tags.findOne({ tag: normalizedTag });
64
+ return !!doc;
65
+ }
@@ -16,13 +16,14 @@
16
16
  * @param {boolean} item.sensitive - Sensitive content flag
17
17
  * @param {Date} item.published - Published date (kept as Date for sort queries)
18
18
  * @param {object} item.author - { name, url, photo, handle }
19
- * @param {string[]} item.category - Tags/categories
19
+ * @param {string[]} item.category - Hashtag strings (# prefix stripped)
20
+ * @param {Array<{name: string, url: string}>} [item.mentions] - @mention entries with actor URLs
20
21
  * @param {string[]} item.photo - Photo URLs
21
22
  * @param {string[]} item.video - Video URLs
22
23
  * @param {string[]} item.audio - Audio URLs
23
24
  * @param {string} [item.inReplyTo] - Parent post URL
24
25
  * @param {object} [item.boostedBy] - { name, url, photo, handle } for boosts
25
- * @param {Date} [item.boostedAt] - Boost timestamp
26
+ * @param {string} [item.boostedAt] - Boost timestamp (ISO string)
26
27
  * @param {string} [item.originalUrl] - Original post URL for boosts
27
28
  * @param {Array<{url: string, title: string, description: string, image: string, favicon: string, domain: string, fetchedAt: string}>} [item.linkPreviews] - OpenGraph link previews for external links in content
28
29
  * @param {string} item.createdAt - ISO string creation timestamp
@@ -59,6 +60,7 @@ export async function addTimelineItem(collections, item) {
59
60
  * @param {number} [options.limit=20] - Items per page
60
61
  * @param {string} [options.type] - Filter by type
61
62
  * @param {string} [options.authorUrl] - Filter by author URL
63
+ * @param {string} [options.tag] - Filter by hashtag (case-insensitive exact match)
62
64
  * @returns {Promise<object>} { items, before, after }
63
65
  */
64
66
  export async function getTimelineItems(collections, options = {}) {
@@ -94,6 +96,17 @@ export async function getTimelineItems(collections, options = {}) {
94
96
  query["author.url"] = options.authorUrl;
95
97
  }
96
98
 
99
+ // Tag filter — case-insensitive exact match against the category[] array
100
+ // Escape regex special chars to prevent injection
101
+ if (options.tag) {
102
+ if (typeof options.tag !== "string") {
103
+ throw new Error("Invalid tag");
104
+ }
105
+
106
+ const escapedTag = options.tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
107
+ query.category = { $regex: new RegExp(`^${escapedTag}$`, "i") };
108
+ }
109
+
97
110
  // Cursor pagination — published is stored as ISO string, so compare
98
111
  // as strings (lexicographic ISO 8601 comparison is correct for dates)
99
112
  if (options.before) {
@@ -3,7 +3,7 @@
3
3
  * @module timeline-store
4
4
  */
5
5
 
6
- import { Article } from "@fedify/fedify/vocab";
6
+ import { Article, Hashtag, Mention } from "@fedify/fedify/vocab";
7
7
  import sanitizeHtml from "sanitize-html";
8
8
 
9
9
  /**
@@ -93,7 +93,9 @@ export async function extractActorInfo(actor, options = {}) {
93
93
  * @param {Date} [options.boostedAt] - Boost timestamp
94
94
  * @param {object} [options.actorFallback] - Fedify actor to use when object.getAttributedTo() fails
95
95
  * @param {object} [options.documentLoader] - Authenticated DocumentLoader for Secure Mode servers
96
- * @returns {Promise<object>} Timeline item data
96
+ * @returns {Promise<object>} Timeline item data with:
97
+ * - category: string[] — hashtag names (stripped of # prefix)
98
+ * - mentions: Array<{name: string, url: string}> — @mention entries with actor URLs
97
99
  */
98
100
  export async function extractObjectData(object, options = {}) {
99
101
  if (!object) {
@@ -185,15 +187,25 @@ export async function extractObjectData(object, options = {}) {
185
187
  }
186
188
  }
187
189
 
188
- // Extract tags/categories — Fedify uses async getTags()
190
+ // Extract tags — Fedify uses async getTags() which returns typed vocab objects.
191
+ // Hashtag → category[] (plain strings, # prefix stripped)
192
+ // Mention → mentions[] ({ name, url } objects for profile linking)
189
193
  const category = [];
194
+ const mentions = [];
190
195
  try {
191
196
  if (typeof object.getTags === "function") {
192
197
  const tags = await object.getTags(loaderOpts);
193
198
  for await (const tag of tags) {
194
- if (tag.name) {
195
- const tagName = tag.name.toString().replace(/^#/, "");
199
+ if (tag instanceof Hashtag) {
200
+ const tagName = tag.name?.toString().replace(/^#/, "") || "";
196
201
  if (tagName) category.push(tagName);
202
+ } else if (tag instanceof Mention) {
203
+ // Strip leading @ from name (Fedify Mention names start with @)
204
+ const rawName = tag.name?.toString() || "";
205
+ const mentionName = rawName.startsWith("@") ? rawName.slice(1) : rawName;
206
+ // tag.href is a URL object — use .href to get the string
207
+ const mentionUrl = tag.href?.href || "";
208
+ if (mentionName) mentions.push({ name: mentionName, url: mentionUrl });
197
209
  }
198
210
  }
199
211
  }
@@ -243,6 +255,7 @@ export async function extractObjectData(object, options = {}) {
243
255
  published,
244
256
  author,
245
257
  category,
258
+ mentions,
246
259
  photo,
247
260
  video,
248
261
  audio,
package/locales/en.json CHANGED
@@ -185,10 +185,6 @@
185
185
  "boosts": "Boosts",
186
186
  "media": "Media"
187
187
  },
188
- "pagination": {
189
- "newer": "← Newer",
190
- "older": "Older →"
191
- },
192
188
  "empty": "Your timeline is empty. Follow some accounts to see their posts here.",
193
189
  "boosted": "boosted",
194
190
  "replyingTo": "Replying to",
@@ -224,10 +220,41 @@
224
220
  "label": "Look up a fediverse post or account",
225
221
  "button": "Look up",
226
222
  "notFoundTitle": "Not found",
227
- "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"
228
225
  },
229
226
  "linkPreview": {
230
227
  "label": "Link preview"
228
+ },
229
+ "explore": {
230
+ "title": "Explore",
231
+ "description": "Browse public timelines from remote Mastodon-compatible instances.",
232
+ "instancePlaceholder": "Enter an instance hostname, e.g. mastodon.social",
233
+ "browse": "Browse",
234
+ "local": "Local",
235
+ "federated": "Federated",
236
+ "loadError": "Could not load timeline from this instance. It may be unavailable or not support the Mastodon API.",
237
+ "timeout": "Request timed out. The instance may be slow or unavailable.",
238
+ "noResults": "No posts found on this instance's public timeline.",
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"
243
+ },
244
+ "tagTimeline": {
245
+ "postsTagged": "%d posts",
246
+ "postsTagged_plural": "%d posts",
247
+ "noPosts": "No posts found with #%s in your timeline.",
248
+ "followTag": "Follow hashtag",
249
+ "unfollowTag": "Unfollow hashtag",
250
+ "following": "Following"
251
+ },
252
+ "pagination": {
253
+ "newer": "← Newer",
254
+ "older": "Older →",
255
+ "loadMore": "Load more",
256
+ "loading": "Loading…",
257
+ "noMore": "You're all caught up."
231
258
  }
232
259
  },
233
260
  "myProfile": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.0.27",
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",
@@ -0,0 +1,126 @@
1
+ {% extends "layouts/ap-reader.njk" %}
2
+
3
+ {% from "prose/macro.njk" import prose with context %}
4
+
5
+ {% block readercontent %}
6
+ {# Page header #}
7
+ <header class="ap-explore-header">
8
+ <h2 class="ap-explore-header__title">{{ __("activitypub.reader.explore.title") }}</h2>
9
+ <p class="ap-explore-header__desc">{{ __("activitypub.reader.explore.description") }}</p>
10
+ </header>
11
+
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">
16
+ <div class="ap-explore-form__row">
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
+
68
+ <div class="ap-explore-form__scope">
69
+ <label class="ap-explore-form__scope-label">
70
+ <input type="radio" name="scope" value="local"
71
+ {% if scope == "local" %}checked{% endif %}>
72
+ {{ __("activitypub.reader.explore.local") }}
73
+ </label>
74
+ <label class="ap-explore-form__scope-label">
75
+ <input type="radio" name="scope" value="federated"
76
+ {% if scope == "federated" %}checked{% endif %}>
77
+ {{ __("activitypub.reader.explore.federated") }}
78
+ </label>
79
+ </div>
80
+ <button type="submit" class="ap-explore-form__btn">
81
+ {{ __("activitypub.reader.explore.browse") }}
82
+ </button>
83
+ </div>
84
+ </form>
85
+
86
+ {# Error state #}
87
+ {% if error %}
88
+ <div class="ap-explore-error">{{ error }}</div>
89
+ {% endif %}
90
+
91
+ {# Results #}
92
+ {% if instance and not error %}
93
+ {% if items.length > 0 %}
94
+ <div class="ap-timeline ap-explore-timeline"
95
+ id="ap-explore-timeline"
96
+ data-instance="{{ instance }}"
97
+ data-scope="{{ scope }}"
98
+ data-mount-path="{{ mountPath }}"
99
+ data-max-id="{{ maxId if maxId else '' }}">
100
+ {% for item in items %}
101
+ {% include "partials/ap-item-card.njk" %}
102
+ {% endfor %}
103
+ </div>
104
+
105
+ {# Infinite scroll for explore page #}
106
+ {% if maxId %}
107
+ <div class="ap-load-more"
108
+ id="ap-explore-load-more"
109
+ data-max-id="{{ maxId }}"
110
+ data-instance="{{ instance }}"
111
+ data-scope="{{ scope }}"
112
+ x-data="apExploreScroll()"
113
+ x-init="init()">
114
+ <div class="ap-load-more__sentinel" x-ref="sentinel"></div>
115
+ <button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
116
+ <span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
117
+ <span x-show="loading">{{ __("activitypub.reader.pagination.loading") }}</span>
118
+ </button>
119
+ <p class="ap-load-more__done" x-show="done" x-cloak>{{ __("activitypub.reader.pagination.noMore") }}</p>
120
+ </div>
121
+ {% endif %}
122
+ {% elif instance %}
123
+ {{ prose({ text: __("activitypub.reader.explore.noResults") }) }}
124
+ {% endif %}
125
+ {% endif %}
126
+ {% endblock %}