@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/assets/reader-autocomplete.js +214 -0
- package/assets/reader-infinite-scroll.js +183 -0
- package/assets/reader.css +438 -0
- package/index.js +43 -0
- package/lib/controllers/api-timeline.js +170 -0
- package/lib/controllers/explore.js +364 -0
- package/lib/controllers/follow-tag.js +62 -0
- package/lib/controllers/reader.js +11 -0
- package/lib/controllers/tag-timeline.js +147 -0
- package/lib/fedidb.js +195 -0
- package/lib/inbox-listeners.js +27 -0
- package/lib/migrations/separate-mentions.js +88 -0
- package/lib/storage/followed-tags.js +65 -0
- package/lib/storage/timeline.js +15 -2
- package/lib/timeline-store.js +18 -5
- package/locales/en.json +32 -5
- package/package.json +1 -1
- package/views/activitypub-explore.njk +126 -0
- package/views/activitypub-reader.njk +80 -8
- package/views/activitypub-tag-timeline.njk +86 -0
- package/views/layouts/ap-reader.njk +6 -1
- package/views/partials/ap-item-card.njk +20 -5
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/lib/inbox-listeners.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/storage/timeline.js
CHANGED
|
@@ -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 -
|
|
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 {
|
|
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) {
|
package/lib/timeline-store.js
CHANGED
|
@@ -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
|
|
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
|
|
195
|
-
const tagName = tag.name
|
|
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.
|
|
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 %}
|