@rmdes/indiekit-endpoint-microsub 1.0.30 → 1.0.32
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/styles.css +78 -0
- package/index.js +12 -0
- package/lib/activitypub/outbox-fetcher.js +255 -0
- package/lib/controllers/reader.js +118 -2
- package/lib/storage/channels.js +39 -2
- package/lib/storage/items.js +132 -42
- package/package.json +1 -1
- package/views/actor.njk +179 -0
- package/views/partials/item-card.njk +12 -1
package/assets/styles.css
CHANGED
|
@@ -936,3 +936,81 @@
|
|
|
936
936
|
.feed-edit__action p {
|
|
937
937
|
margin-bottom: var(--space-s);
|
|
938
938
|
}
|
|
939
|
+
|
|
940
|
+
/* ==========================================================================
|
|
941
|
+
Actor Profile
|
|
942
|
+
========================================================================== */
|
|
943
|
+
|
|
944
|
+
.actor-profile {
|
|
945
|
+
background: var(--color-offset);
|
|
946
|
+
border-radius: var(--border-radius);
|
|
947
|
+
margin-bottom: var(--space-m);
|
|
948
|
+
padding: var(--space-m);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
.actor-profile__header {
|
|
952
|
+
align-items: flex-start;
|
|
953
|
+
display: flex;
|
|
954
|
+
gap: var(--space-m);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
.actor-profile__avatar {
|
|
958
|
+
border-radius: 50%;
|
|
959
|
+
flex-shrink: 0;
|
|
960
|
+
object-fit: cover;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
.actor-profile__info {
|
|
964
|
+
flex: 1;
|
|
965
|
+
min-width: 0;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
.actor-profile__name {
|
|
969
|
+
font-size: 1.25em;
|
|
970
|
+
margin: 0 0 2px;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
.actor-profile__handle {
|
|
974
|
+
color: var(--color-text-muted, #666);
|
|
975
|
+
font-size: 0.9em;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
.actor-profile__summary {
|
|
979
|
+
font-size: 0.9em;
|
|
980
|
+
margin: var(--space-xs) 0 0;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
.actor-profile__stats {
|
|
984
|
+
color: var(--color-text-muted, #666);
|
|
985
|
+
display: flex;
|
|
986
|
+
font-size: 0.85em;
|
|
987
|
+
gap: var(--space-m);
|
|
988
|
+
margin-top: var(--space-xs);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
.actor-profile__actions {
|
|
992
|
+
display: flex;
|
|
993
|
+
gap: var(--space-s);
|
|
994
|
+
margin-top: var(--space-s);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/* ==========================================================================
|
|
998
|
+
AP Badge
|
|
999
|
+
========================================================================== */
|
|
1000
|
+
|
|
1001
|
+
.item-card__badge {
|
|
1002
|
+
border-radius: 3px;
|
|
1003
|
+
display: inline-block;
|
|
1004
|
+
font-size: 0.7em;
|
|
1005
|
+
font-weight: 600;
|
|
1006
|
+
letter-spacing: 0.02em;
|
|
1007
|
+
line-height: 1;
|
|
1008
|
+
padding: 2px 4px;
|
|
1009
|
+
text-transform: uppercase;
|
|
1010
|
+
vertical-align: middle;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
.item-card__badge--ap {
|
|
1014
|
+
background: #7c3aed20;
|
|
1015
|
+
color: #7c3aed;
|
|
1016
|
+
}
|
package/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { opmlController } from "./lib/controllers/opml.js";
|
|
|
8
8
|
import { readerController } from "./lib/controllers/reader.js";
|
|
9
9
|
import { handleMediaProxy } from "./lib/media/proxy.js";
|
|
10
10
|
import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
|
|
11
|
+
import { ensureActivityPubChannel } from "./lib/storage/channels.js";
|
|
11
12
|
import { cleanupAllReadItems, createIndexes } from "./lib/storage/items.js";
|
|
12
13
|
import { webmentionReceiver } from "./lib/webmention/receiver.js";
|
|
13
14
|
import { websubHandler } from "./lib/websub/handler.js";
|
|
@@ -126,6 +127,9 @@ export default class MicrosubEndpoint {
|
|
|
126
127
|
readerRouter.get("/search", readerController.searchPage);
|
|
127
128
|
readerRouter.post("/search", readerController.searchFeeds);
|
|
128
129
|
readerRouter.post("/subscribe", readerController.subscribe);
|
|
130
|
+
readerRouter.get("/actor", readerController.actorProfile);
|
|
131
|
+
readerRouter.post("/actor/follow", readerController.followActorAction);
|
|
132
|
+
readerRouter.post("/actor/unfollow", readerController.unfollowActorAction);
|
|
129
133
|
readerRouter.post("/api/mark-read", readerController.markAllRead);
|
|
130
134
|
readerRouter.get("/opml", opmlController.exportOpml);
|
|
131
135
|
router.use("/reader", readerRouter);
|
|
@@ -184,6 +188,14 @@ export default class MicrosubEndpoint {
|
|
|
184
188
|
console.info("[Microsub] Database available, starting scheduler");
|
|
185
189
|
startScheduler(indiekit);
|
|
186
190
|
|
|
191
|
+
// Ensure system channels exist
|
|
192
|
+
ensureActivityPubChannel(indiekit).catch((error) => {
|
|
193
|
+
console.warn(
|
|
194
|
+
"[Microsub] ActivityPub channel creation failed:",
|
|
195
|
+
error.message,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
187
199
|
// Create indexes for optimal performance (runs in background)
|
|
188
200
|
createIndexes(indiekit).catch((error) => {
|
|
189
201
|
console.warn("[Microsub] Index creation failed:", error.message);
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch a remote ActivityPub actor's outbox for on-demand reading.
|
|
3
|
+
* Returns ephemeral jf2 items — nothing is stored in MongoDB.
|
|
4
|
+
*
|
|
5
|
+
* @module activitypub/outbox-fetcher
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const AP_ACCEPT =
|
|
9
|
+
'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
|
|
10
|
+
const FETCH_TIMEOUT = 10_000;
|
|
11
|
+
const USER_AGENT = "Indiekit/1.0 (Microsub reader)";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fetch a remote actor's profile and recent posts from their outbox.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} actorUrl - Full URL of the AP actor
|
|
17
|
+
* @param {object} [options]
|
|
18
|
+
* @param {number} [options.limit=20] - Max items to return
|
|
19
|
+
* @returns {Promise<{ actor: object, items: Array }>}
|
|
20
|
+
*/
|
|
21
|
+
export async function fetchActorOutbox(actorUrl, options = {}) {
|
|
22
|
+
const limit = options.limit || 20;
|
|
23
|
+
|
|
24
|
+
// 1. Fetch actor profile
|
|
25
|
+
const actor = await fetchJson(actorUrl);
|
|
26
|
+
if (!actor || !actor.outbox) {
|
|
27
|
+
throw new Error("Could not resolve actor or outbox URL");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const actorInfo = {
|
|
31
|
+
name:
|
|
32
|
+
actor.name ||
|
|
33
|
+
actor.preferredUsername ||
|
|
34
|
+
new URL(actorUrl).pathname.split("/").pop(),
|
|
35
|
+
url: actor.url || actor.id || actorUrl,
|
|
36
|
+
photo: actor.icon?.url || actor.icon || "",
|
|
37
|
+
summary: stripHtml(actor.summary || ""),
|
|
38
|
+
handle: actor.preferredUsername || "",
|
|
39
|
+
followersCount: 0,
|
|
40
|
+
followingCount: 0,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Resolve follower/following counts if available
|
|
44
|
+
if (typeof actor.followers === "string") {
|
|
45
|
+
try {
|
|
46
|
+
const followersCollection = await fetchJson(actor.followers);
|
|
47
|
+
actorInfo.followersCount = followersCollection?.totalItems || 0;
|
|
48
|
+
} catch {
|
|
49
|
+
/* ignore */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (typeof actor.following === "string") {
|
|
53
|
+
try {
|
|
54
|
+
const followingCollection = await fetchJson(actor.following);
|
|
55
|
+
actorInfo.followingCount = followingCollection?.totalItems || 0;
|
|
56
|
+
} catch {
|
|
57
|
+
/* ignore */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2. Fetch outbox (OrderedCollection)
|
|
62
|
+
const outboxUrl =
|
|
63
|
+
typeof actor.outbox === "string" ? actor.outbox : actor.outbox?.id;
|
|
64
|
+
const outbox = await fetchJson(outboxUrl);
|
|
65
|
+
if (!outbox) {
|
|
66
|
+
return { actor: actorInfo, items: [] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 3. Get items — may be inline or on a first page
|
|
70
|
+
let activities = [];
|
|
71
|
+
|
|
72
|
+
if (outbox.orderedItems?.length > 0) {
|
|
73
|
+
activities = outbox.orderedItems;
|
|
74
|
+
} else if (outbox.first) {
|
|
75
|
+
const firstPageUrl =
|
|
76
|
+
typeof outbox.first === "string" ? outbox.first : outbox.first?.id;
|
|
77
|
+
if (firstPageUrl) {
|
|
78
|
+
const firstPage = await fetchJson(firstPageUrl);
|
|
79
|
+
activities = firstPage?.orderedItems || firstPage?.items || [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 4. Convert Create activities to jf2 items
|
|
84
|
+
const items = [];
|
|
85
|
+
for (const activity of activities) {
|
|
86
|
+
if (items.length >= limit) break;
|
|
87
|
+
|
|
88
|
+
const item = activityToJf2(activity, actorInfo);
|
|
89
|
+
if (item) items.push(item);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { actor: actorInfo, items };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Convert a single AP activity (or bare object) to jf2 format.
|
|
97
|
+
* @param {object} activity - AP activity or object
|
|
98
|
+
* @param {object} actorInfo - Actor profile info
|
|
99
|
+
* @returns {object|null} jf2 item or null if not displayable
|
|
100
|
+
*/
|
|
101
|
+
function activityToJf2(activity, actorInfo) {
|
|
102
|
+
// Unwrap Create/Announce — the displayable content is the inner object
|
|
103
|
+
let object = activity;
|
|
104
|
+
const activityType = activity.type;
|
|
105
|
+
|
|
106
|
+
if (activityType === "Create" || activityType === "Announce") {
|
|
107
|
+
object = activity.object;
|
|
108
|
+
if (!object || typeof object === "string") return null; // Unresolved reference
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Skip non-content types (Follow, Like, etc.)
|
|
112
|
+
const contentTypes = new Set([
|
|
113
|
+
"Note",
|
|
114
|
+
"Article",
|
|
115
|
+
"Page",
|
|
116
|
+
"Video",
|
|
117
|
+
"Audio",
|
|
118
|
+
"Image",
|
|
119
|
+
"Event",
|
|
120
|
+
"Question",
|
|
121
|
+
]);
|
|
122
|
+
if (!contentTypes.has(object.type)) return null;
|
|
123
|
+
|
|
124
|
+
const contentHtml = object.content || "";
|
|
125
|
+
const contentText = stripHtml(contentHtml);
|
|
126
|
+
|
|
127
|
+
const jf2 = {
|
|
128
|
+
type: "entry",
|
|
129
|
+
url: object.url || object.id || "",
|
|
130
|
+
uid: object.id || object.url || "",
|
|
131
|
+
name: object.name || undefined,
|
|
132
|
+
content: contentHtml ? { text: contentText, html: contentHtml } : undefined,
|
|
133
|
+
summary: object.summary ? stripHtml(object.summary) : undefined,
|
|
134
|
+
published: object.published || activity.published || undefined,
|
|
135
|
+
author: {
|
|
136
|
+
name: actorInfo.name,
|
|
137
|
+
url: actorInfo.url,
|
|
138
|
+
photo: actorInfo.photo,
|
|
139
|
+
},
|
|
140
|
+
category: extractTags(object.tag),
|
|
141
|
+
photo: extractMedia(object.attachment, "image"),
|
|
142
|
+
video: extractMedia(object.attachment, "video"),
|
|
143
|
+
audio: extractMedia(object.attachment, "audio"),
|
|
144
|
+
_source: { type: "activitypub", actorUrl: actorInfo.url },
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Boost attribution
|
|
148
|
+
if (activityType === "Announce" && activity.actor) {
|
|
149
|
+
jf2._boostedBy = actorInfo;
|
|
150
|
+
// The inner object may have its own author
|
|
151
|
+
if (object.attributedTo) {
|
|
152
|
+
const attributedUrl =
|
|
153
|
+
typeof object.attributedTo === "string"
|
|
154
|
+
? object.attributedTo
|
|
155
|
+
: object.attributedTo?.id || object.attributedTo?.url;
|
|
156
|
+
if (attributedUrl) {
|
|
157
|
+
jf2.author = {
|
|
158
|
+
name:
|
|
159
|
+
object.attributedTo?.name ||
|
|
160
|
+
object.attributedTo?.preferredUsername ||
|
|
161
|
+
attributedUrl,
|
|
162
|
+
url: attributedUrl,
|
|
163
|
+
photo: object.attributedTo?.icon?.url || "",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (object.inReplyTo) {
|
|
170
|
+
const replyUrl =
|
|
171
|
+
typeof object.inReplyTo === "string"
|
|
172
|
+
? object.inReplyTo
|
|
173
|
+
: object.inReplyTo?.id;
|
|
174
|
+
if (replyUrl) jf2["in-reply-to"] = [replyUrl];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return jf2;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Extract hashtags from AP tag array.
|
|
182
|
+
* @param {Array} tags - AP tag objects
|
|
183
|
+
* @returns {Array<string>}
|
|
184
|
+
*/
|
|
185
|
+
function extractTags(tags) {
|
|
186
|
+
if (!Array.isArray(tags)) return [];
|
|
187
|
+
return tags
|
|
188
|
+
.filter((t) => t.type === "Hashtag" || t.type === "Tag")
|
|
189
|
+
.map((t) => (t.name || "").replace(/^#/, ""))
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Extract media URLs from AP attachment array.
|
|
195
|
+
* @param {Array} attachments - AP attachment objects
|
|
196
|
+
* @param {string} mediaPrefix - "image", "video", or "audio"
|
|
197
|
+
* @returns {Array<string>}
|
|
198
|
+
*/
|
|
199
|
+
function extractMedia(attachments, mediaPrefix) {
|
|
200
|
+
if (!Array.isArray(attachments)) return [];
|
|
201
|
+
return attachments
|
|
202
|
+
.filter((a) => (a.mediaType || "").startsWith(`${mediaPrefix}/`))
|
|
203
|
+
.map((a) => a.url || a.href || "")
|
|
204
|
+
.filter(Boolean);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Fetch a URL as ActivityPub JSON.
|
|
209
|
+
* @param {string} url
|
|
210
|
+
* @returns {Promise<object|null>}
|
|
211
|
+
*/
|
|
212
|
+
async function fetchJson(url) {
|
|
213
|
+
if (!url) return null;
|
|
214
|
+
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const response = await fetch(url, {
|
|
220
|
+
headers: {
|
|
221
|
+
Accept: AP_ACCEPT,
|
|
222
|
+
"User-Agent": USER_AGENT,
|
|
223
|
+
},
|
|
224
|
+
signal: controller.signal,
|
|
225
|
+
redirect: "follow",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (!response.ok) {
|
|
229
|
+
console.warn(
|
|
230
|
+
`[Microsub] AP fetch failed: ${response.status} for ${url}`,
|
|
231
|
+
);
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return await response.json();
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error.name === "AbortError") {
|
|
238
|
+
console.warn(`[Microsub] AP fetch timeout for ${url}`);
|
|
239
|
+
} else {
|
|
240
|
+
console.warn(`[Microsub] AP fetch error for ${url}: ${error.message}`);
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
} finally {
|
|
244
|
+
clearTimeout(timeout);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Strip HTML tags for plain text.
|
|
250
|
+
* @param {string} html
|
|
251
|
+
* @returns {string}
|
|
252
|
+
*/
|
|
253
|
+
function stripHtml(html) {
|
|
254
|
+
return (html || "").replace(/<[^>]*>/g, "").trim();
|
|
255
|
+
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
markItemsRead,
|
|
29
29
|
countReadItems,
|
|
30
30
|
} from "../storage/items.js";
|
|
31
|
+
import { fetchActorOutbox } from "../activitypub/outbox-fetcher.js";
|
|
31
32
|
import { getUserId } from "../utils/auth.js";
|
|
32
33
|
import {
|
|
33
34
|
validateChannelName,
|
|
@@ -207,8 +208,8 @@ export async function deleteChannelAction(request, response) {
|
|
|
207
208
|
const userId = getUserId(request);
|
|
208
209
|
const { uid } = request.params;
|
|
209
210
|
|
|
210
|
-
// Don't allow deleting
|
|
211
|
-
if (uid === "notifications") {
|
|
211
|
+
// Don't allow deleting system channels
|
|
212
|
+
if (uid === "notifications" || uid === "activitypub") {
|
|
212
213
|
return response.redirect(`${request.baseUrl}/channels`);
|
|
213
214
|
}
|
|
214
215
|
|
|
@@ -909,6 +910,118 @@ export async function refreshFeed(request, response) {
|
|
|
909
910
|
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
|
910
911
|
}
|
|
911
912
|
|
|
913
|
+
/**
|
|
914
|
+
* Actor profile — fetch and display a remote AP actor's recent posts
|
|
915
|
+
* @param {object} request - Express request
|
|
916
|
+
* @param {object} response - Express response
|
|
917
|
+
*/
|
|
918
|
+
/**
|
|
919
|
+
* Find the ActivityPub plugin instance from installed plugins.
|
|
920
|
+
* @param {object} request - Express request
|
|
921
|
+
* @returns {object|undefined} The AP plugin instance
|
|
922
|
+
*/
|
|
923
|
+
function getApPlugin(request) {
|
|
924
|
+
const installedPlugins = request.app.locals.installedPlugins;
|
|
925
|
+
if (!installedPlugins) return undefined;
|
|
926
|
+
return [...installedPlugins].find(
|
|
927
|
+
(p) => p.name === "ActivityPub endpoint",
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
export async function actorProfile(request, response) {
|
|
932
|
+
const actorUrl = request.query.url;
|
|
933
|
+
if (!actorUrl) {
|
|
934
|
+
return response.status(400).render("404");
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Check if we already follow this actor
|
|
938
|
+
const { application } = request.app.locals;
|
|
939
|
+
const apFollowing = application?.collections?.get("ap_following");
|
|
940
|
+
let isFollowing = false;
|
|
941
|
+
if (apFollowing) {
|
|
942
|
+
const existing = await apFollowing.findOne({ actorUrl });
|
|
943
|
+
isFollowing = !!existing;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Check if AP plugin is available (for follow button visibility)
|
|
947
|
+
const apPlugin = getApPlugin(request);
|
|
948
|
+
const canFollow = !!apPlugin;
|
|
949
|
+
|
|
950
|
+
try {
|
|
951
|
+
const { actor, items } = await fetchActorOutbox(actorUrl, { limit: 30 });
|
|
952
|
+
|
|
953
|
+
response.render("actor", {
|
|
954
|
+
title: actor.name || "Actor",
|
|
955
|
+
actor,
|
|
956
|
+
items,
|
|
957
|
+
actorUrl,
|
|
958
|
+
isFollowing,
|
|
959
|
+
canFollow,
|
|
960
|
+
baseUrl: request.baseUrl,
|
|
961
|
+
});
|
|
962
|
+
} catch (error) {
|
|
963
|
+
console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
|
|
964
|
+
response.render("actor", {
|
|
965
|
+
title: "Actor",
|
|
966
|
+
actor: { name: actorUrl, url: actorUrl, photo: "", summary: "" },
|
|
967
|
+
items: [],
|
|
968
|
+
actorUrl,
|
|
969
|
+
isFollowing,
|
|
970
|
+
canFollow,
|
|
971
|
+
baseUrl: request.baseUrl,
|
|
972
|
+
error: "Could not fetch this actor's profile. They may have restricted access.",
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
export async function followActorAction(request, response) {
|
|
978
|
+
const { actorUrl, actorName } = request.body;
|
|
979
|
+
if (!actorUrl) {
|
|
980
|
+
return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const apPlugin = getApPlugin(request);
|
|
984
|
+
if (!apPlugin) {
|
|
985
|
+
console.error("[Microsub] Cannot follow: ActivityPub plugin not installed");
|
|
986
|
+
return response.redirect(
|
|
987
|
+
`${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const result = await apPlugin.followActor(actorUrl, { name: actorName });
|
|
992
|
+
if (!result.ok) {
|
|
993
|
+
console.error(`[Microsub] Follow via AP plugin failed: ${result.error}`);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
return response.redirect(
|
|
997
|
+
`${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
export async function unfollowActorAction(request, response) {
|
|
1002
|
+
const { actorUrl } = request.body;
|
|
1003
|
+
if (!actorUrl) {
|
|
1004
|
+
return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const apPlugin = getApPlugin(request);
|
|
1008
|
+
if (!apPlugin) {
|
|
1009
|
+
console.error("[Microsub] Cannot unfollow: ActivityPub plugin not installed");
|
|
1010
|
+
return response.redirect(
|
|
1011
|
+
`${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const result = await apPlugin.unfollowActor(actorUrl);
|
|
1016
|
+
if (!result.ok) {
|
|
1017
|
+
console.error(`[Microsub] Unfollow via AP plugin failed: ${result.error}`);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
return response.redirect(
|
|
1021
|
+
`${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
912
1025
|
export const readerController = {
|
|
913
1026
|
index,
|
|
914
1027
|
channels,
|
|
@@ -933,4 +1046,7 @@ export const readerController = {
|
|
|
933
1046
|
searchPage,
|
|
934
1047
|
searchFeeds,
|
|
935
1048
|
subscribe,
|
|
1049
|
+
actorProfile,
|
|
1050
|
+
followActorAction,
|
|
1051
|
+
unfollowActorAction,
|
|
936
1052
|
};
|
package/lib/storage/channels.js
CHANGED
|
@@ -115,6 +115,7 @@ export async function getChannels(application, userId) {
|
|
|
115
115
|
channelId: channel._id,
|
|
116
116
|
readBy: { $ne: userId },
|
|
117
117
|
published: { $gte: cutoffDate },
|
|
118
|
+
_stripped: { $ne: true },
|
|
118
119
|
});
|
|
119
120
|
|
|
120
121
|
return {
|
|
@@ -206,8 +207,8 @@ export async function deleteChannel(application, uid, userId) {
|
|
|
206
207
|
const query = { uid };
|
|
207
208
|
if (userId) query.userId = userId;
|
|
208
209
|
|
|
209
|
-
// Don't allow deleting
|
|
210
|
-
if (uid === "notifications") {
|
|
210
|
+
// Don't allow deleting system channels
|
|
211
|
+
if (uid === "notifications" || uid === "activitypub") {
|
|
211
212
|
return false;
|
|
212
213
|
}
|
|
213
214
|
|
|
@@ -305,3 +306,39 @@ export async function ensureNotificationsChannel(application, userId) {
|
|
|
305
306
|
await collection.insertOne(channel);
|
|
306
307
|
return channel;
|
|
307
308
|
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Ensure ActivityPub channel exists
|
|
312
|
+
* @param {object} application - Indiekit application
|
|
313
|
+
* @param {string} [userId] - User ID
|
|
314
|
+
* @returns {Promise<object>} ActivityPub channel
|
|
315
|
+
*/
|
|
316
|
+
export async function ensureActivityPubChannel(application, userId) {
|
|
317
|
+
const collection = getCollection(application);
|
|
318
|
+
|
|
319
|
+
const existing = await collection.findOne({
|
|
320
|
+
uid: "activitypub",
|
|
321
|
+
...(userId && { userId }),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
if (existing) {
|
|
325
|
+
return existing;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const channel = {
|
|
329
|
+
uid: "activitypub",
|
|
330
|
+
name: "Fediverse",
|
|
331
|
+
userId,
|
|
332
|
+
source: "activitypub",
|
|
333
|
+
order: -0.5, // After notifications (-1), before user channels (0+)
|
|
334
|
+
settings: {
|
|
335
|
+
excludeTypes: [],
|
|
336
|
+
excludeRegex: undefined,
|
|
337
|
+
},
|
|
338
|
+
createdAt: new Date().toISOString(),
|
|
339
|
+
updatedAt: new Date().toISOString(),
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
await collection.insertOne(channel);
|
|
343
|
+
return channel;
|
|
344
|
+
}
|
package/lib/storage/items.js
CHANGED
|
@@ -87,8 +87,9 @@ export async function getTimelineItems(application, channelId, options = {}) {
|
|
|
87
87
|
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
88
88
|
const limit = parseLimit(options.limit);
|
|
89
89
|
|
|
90
|
-
// Base query - filter out read items unless showRead is true
|
|
91
|
-
|
|
90
|
+
// Base query - filter out read items unless showRead is true,
|
|
91
|
+
// and always exclude stripped dedup skeletons (no content to display)
|
|
92
|
+
const baseQuery = { channelId: objectId, _stripped: { $ne: true } };
|
|
92
93
|
if (options.userId && !options.showRead) {
|
|
93
94
|
baseQuery.readBy = { $ne: options.userId };
|
|
94
95
|
}
|
|
@@ -288,61 +289,109 @@ export async function countReadItems(application, channelId, userId) {
|
|
|
288
289
|
* @param {string} userId - User ID
|
|
289
290
|
* @returns {Promise<number>} Number of items updated
|
|
290
291
|
*/
|
|
291
|
-
// Maximum number of read items to keep per channel
|
|
292
|
-
|
|
292
|
+
// Maximum number of full read items to keep per channel before stripping content.
|
|
293
|
+
// Items beyond this limit are converted to lightweight dedup skeletons (channelId,
|
|
294
|
+
// uid, readBy) so the poller doesn't re-ingest them as new unread entries.
|
|
295
|
+
const MAX_FULL_READ_ITEMS = 200;
|
|
293
296
|
|
|
294
297
|
/**
|
|
295
|
-
* Cleanup old read items
|
|
298
|
+
* Cleanup old read items by stripping content but preserving dedup skeletons.
|
|
299
|
+
* This prevents the vicious cycle where deleted read items get re-ingested as
|
|
300
|
+
* unread by the poller because the dedup record (channelId + uid) was destroyed.
|
|
301
|
+
*
|
|
302
|
+
* AP items (feedId: null) are hard-deleted instead of stripped, since no poller
|
|
303
|
+
* re-ingests them — they arrive via inbox push and don't need dedup skeletons.
|
|
304
|
+
*
|
|
296
305
|
* @param {object} collection - MongoDB collection
|
|
297
306
|
* @param {ObjectId} channelObjectId - Channel ObjectId
|
|
298
307
|
* @param {string} userId - User ID
|
|
299
308
|
*/
|
|
300
309
|
async function cleanupOldReadItems(collection, channelObjectId, userId) {
|
|
301
|
-
// Count read items in this channel
|
|
302
310
|
const readCount = await collection.countDocuments({
|
|
303
311
|
channelId: channelObjectId,
|
|
304
312
|
readBy: userId,
|
|
305
313
|
});
|
|
306
314
|
|
|
307
|
-
if (readCount >
|
|
308
|
-
// Find
|
|
309
|
-
const
|
|
315
|
+
if (readCount > MAX_FULL_READ_ITEMS) {
|
|
316
|
+
// Find old read items beyond the retention limit
|
|
317
|
+
const itemsToCleanup = await collection
|
|
310
318
|
.find({
|
|
311
319
|
channelId: channelObjectId,
|
|
312
320
|
readBy: userId,
|
|
321
|
+
_stripped: { $ne: true },
|
|
313
322
|
})
|
|
314
|
-
.sort({ published: -1, _id: -1 })
|
|
315
|
-
.skip(
|
|
316
|
-
.project({ _id: 1 })
|
|
323
|
+
.sort({ published: -1, _id: -1 })
|
|
324
|
+
.skip(MAX_FULL_READ_ITEMS)
|
|
325
|
+
.project({ _id: 1, feedId: 1 })
|
|
317
326
|
.toArray();
|
|
318
327
|
|
|
319
|
-
if (
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
328
|
+
if (itemsToCleanup.length === 0) return;
|
|
329
|
+
|
|
330
|
+
// Separate AP items (feedId: null) from RSS items (feedId: ObjectId)
|
|
331
|
+
const apItemIds = [];
|
|
332
|
+
const rssItemIds = [];
|
|
333
|
+
for (const item of itemsToCleanup) {
|
|
334
|
+
if (item.feedId) {
|
|
335
|
+
rssItemIds.push(item._id);
|
|
336
|
+
} else {
|
|
337
|
+
apItemIds.push(item._id);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Hard-delete AP items — no poller to re-ingest, skeletons are useless
|
|
342
|
+
if (apItemIds.length > 0) {
|
|
343
|
+
const deleted = await collection.deleteMany({
|
|
344
|
+
_id: { $in: apItemIds },
|
|
323
345
|
});
|
|
324
346
|
console.info(
|
|
325
|
-
`[Microsub]
|
|
347
|
+
`[Microsub] Deleted ${deleted.deletedCount} old AP read items`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Strip RSS items to dedup skeletons — poller would re-ingest if deleted
|
|
352
|
+
if (rssItemIds.length > 0) {
|
|
353
|
+
const stripped = await collection.updateMany(
|
|
354
|
+
{ _id: { $in: rssItemIds } },
|
|
355
|
+
{
|
|
356
|
+
$set: { _stripped: true },
|
|
357
|
+
$unset: {
|
|
358
|
+
name: "",
|
|
359
|
+
content: "",
|
|
360
|
+
summary: "",
|
|
361
|
+
author: "",
|
|
362
|
+
category: "",
|
|
363
|
+
photo: "",
|
|
364
|
+
video: "",
|
|
365
|
+
audio: "",
|
|
366
|
+
likeOf: "",
|
|
367
|
+
repostOf: "",
|
|
368
|
+
bookmarkOf: "",
|
|
369
|
+
inReplyTo: "",
|
|
370
|
+
source: "",
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
);
|
|
374
|
+
console.info(
|
|
375
|
+
`[Microsub] Stripped ${stripped.modifiedCount} old RSS read items (keeping ${MAX_FULL_READ_ITEMS} full)`,
|
|
326
376
|
);
|
|
327
377
|
}
|
|
328
378
|
}
|
|
329
379
|
}
|
|
330
380
|
|
|
331
381
|
/**
|
|
332
|
-
* Cleanup all read items across all channels (startup cleanup)
|
|
382
|
+
* Cleanup all read items across all channels (startup cleanup).
|
|
383
|
+
* RSS items are stripped to dedup skeletons; AP items are hard-deleted.
|
|
333
384
|
* @param {object} application - Indiekit application
|
|
334
|
-
* @returns {Promise<number>} Total number of items
|
|
385
|
+
* @returns {Promise<number>} Total number of items cleaned up
|
|
335
386
|
*/
|
|
336
387
|
export async function cleanupAllReadItems(application) {
|
|
337
388
|
const collection = getCollection(application);
|
|
338
389
|
const channelsCollection = application.collections.get("microsub_channels");
|
|
339
390
|
|
|
340
|
-
// Get all channels
|
|
341
391
|
const channels = await channelsCollection.find({}).toArray();
|
|
342
|
-
let
|
|
392
|
+
let totalCleaned = 0;
|
|
343
393
|
|
|
344
394
|
for (const channel of channels) {
|
|
345
|
-
// Get unique userIds who have read items in this channel
|
|
346
395
|
const readByUsers = await collection.distinct("readBy", {
|
|
347
396
|
channelId: channel._id,
|
|
348
397
|
readBy: { $exists: true, $ne: [] },
|
|
@@ -354,40 +403,83 @@ export async function cleanupAllReadItems(application) {
|
|
|
354
403
|
const readCount = await collection.countDocuments({
|
|
355
404
|
channelId: channel._id,
|
|
356
405
|
readBy: userId,
|
|
406
|
+
_stripped: { $ne: true },
|
|
357
407
|
});
|
|
358
408
|
|
|
359
|
-
if (readCount >
|
|
360
|
-
const
|
|
409
|
+
if (readCount > MAX_FULL_READ_ITEMS) {
|
|
410
|
+
const itemsToCleanup = await collection
|
|
361
411
|
.find({
|
|
362
412
|
channelId: channel._id,
|
|
363
413
|
readBy: userId,
|
|
414
|
+
_stripped: { $ne: true },
|
|
364
415
|
})
|
|
365
416
|
.sort({ published: -1, _id: -1 })
|
|
366
|
-
.skip(
|
|
367
|
-
.project({ _id: 1 })
|
|
417
|
+
.skip(MAX_FULL_READ_ITEMS)
|
|
418
|
+
.project({ _id: 1, feedId: 1 })
|
|
368
419
|
.toArray();
|
|
369
420
|
|
|
370
|
-
if (
|
|
371
|
-
const
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
421
|
+
if (itemsToCleanup.length > 0) {
|
|
422
|
+
const apItemIds = [];
|
|
423
|
+
const rssItemIds = [];
|
|
424
|
+
for (const item of itemsToCleanup) {
|
|
425
|
+
if (item.feedId) {
|
|
426
|
+
rssItemIds.push(item._id);
|
|
427
|
+
} else {
|
|
428
|
+
apItemIds.push(item._id);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Hard-delete AP items
|
|
433
|
+
if (apItemIds.length > 0) {
|
|
434
|
+
const deleted = await collection.deleteMany({
|
|
435
|
+
_id: { $in: apItemIds },
|
|
436
|
+
});
|
|
437
|
+
totalCleaned += deleted.deletedCount;
|
|
438
|
+
console.info(
|
|
439
|
+
`[Microsub] Startup cleanup: deleted ${deleted.deletedCount} AP items from channel "${channel.name}"`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Strip RSS items to skeletons
|
|
444
|
+
if (rssItemIds.length > 0) {
|
|
445
|
+
const stripped = await collection.updateMany(
|
|
446
|
+
{ _id: { $in: rssItemIds } },
|
|
447
|
+
{
|
|
448
|
+
$set: { _stripped: true },
|
|
449
|
+
$unset: {
|
|
450
|
+
name: "",
|
|
451
|
+
content: "",
|
|
452
|
+
summary: "",
|
|
453
|
+
author: "",
|
|
454
|
+
category: "",
|
|
455
|
+
photo: "",
|
|
456
|
+
video: "",
|
|
457
|
+
audio: "",
|
|
458
|
+
likeOf: "",
|
|
459
|
+
repostOf: "",
|
|
460
|
+
bookmarkOf: "",
|
|
461
|
+
inReplyTo: "",
|
|
462
|
+
source: "",
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
);
|
|
466
|
+
totalCleaned += stripped.modifiedCount;
|
|
467
|
+
console.info(
|
|
468
|
+
`[Microsub] Startup cleanup: stripped ${stripped.modifiedCount} RSS items from channel "${channel.name}"`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
379
471
|
}
|
|
380
472
|
}
|
|
381
473
|
}
|
|
382
474
|
}
|
|
383
475
|
|
|
384
|
-
if (
|
|
476
|
+
if (totalCleaned > 0) {
|
|
385
477
|
console.info(
|
|
386
|
-
`[Microsub] Startup cleanup complete: ${
|
|
478
|
+
`[Microsub] Startup cleanup complete: ${totalCleaned} total items cleaned`,
|
|
387
479
|
);
|
|
388
480
|
}
|
|
389
481
|
|
|
390
|
-
return
|
|
482
|
+
return totalCleaned;
|
|
391
483
|
}
|
|
392
484
|
|
|
393
485
|
export async function markItemsRead(application, channelId, entryIds, userId) {
|
|
@@ -446,9 +538,6 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
|
|
|
446
538
|
`[Microsub] markItemsRead result: ${result.modifiedCount} items updated`,
|
|
447
539
|
);
|
|
448
540
|
|
|
449
|
-
// Cleanup old read items, keeping only the most recent
|
|
450
|
-
await cleanupOldReadItems(collection, channelObjectId, userId);
|
|
451
|
-
|
|
452
541
|
return result.modifiedCount;
|
|
453
542
|
}
|
|
454
543
|
|
|
@@ -577,7 +666,7 @@ export async function getUnreadCount(application, channelId, userId) {
|
|
|
577
666
|
const objectId =
|
|
578
667
|
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
579
668
|
|
|
580
|
-
// Only count items from the last UNREAD_RETENTION_DAYS
|
|
669
|
+
// Only count items from the last UNREAD_RETENTION_DAYS, exclude stripped skeletons
|
|
581
670
|
const cutoffDate = new Date();
|
|
582
671
|
cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS);
|
|
583
672
|
|
|
@@ -585,6 +674,7 @@ export async function getUnreadCount(application, channelId, userId) {
|
|
|
585
674
|
channelId: objectId,
|
|
586
675
|
readBy: { $ne: userId },
|
|
587
676
|
published: { $gte: cutoffDate },
|
|
677
|
+
_stripped: { $ne: true },
|
|
588
678
|
});
|
|
589
679
|
}
|
|
590
680
|
|
package/package.json
CHANGED
package/views/actor.njk
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
{% extends "layouts/reader.njk" %}
|
|
2
|
+
|
|
3
|
+
{% block reader %}
|
|
4
|
+
<div class="channel">
|
|
5
|
+
<header class="channel__header">
|
|
6
|
+
<a href="{{ baseUrl }}/channels/activitypub" class="back-link">
|
|
7
|
+
{{ icon("previous") }} Fediverse
|
|
8
|
+
</a>
|
|
9
|
+
</header>
|
|
10
|
+
|
|
11
|
+
{# Actor profile card #}
|
|
12
|
+
<div class="actor-profile">
|
|
13
|
+
<div class="actor-profile__header">
|
|
14
|
+
{% if actor.photo %}
|
|
15
|
+
<img src="{{ actor.photo }}"
|
|
16
|
+
alt=""
|
|
17
|
+
class="actor-profile__avatar"
|
|
18
|
+
width="80"
|
|
19
|
+
height="80"
|
|
20
|
+
onerror="this.style.display='none'">
|
|
21
|
+
{% endif %}
|
|
22
|
+
<div class="actor-profile__info">
|
|
23
|
+
<h2 class="actor-profile__name">{{ actor.name }}</h2>
|
|
24
|
+
{% if actor.handle %}
|
|
25
|
+
<span class="actor-profile__handle">@{{ actor.handle }}</span>
|
|
26
|
+
{% endif %}
|
|
27
|
+
{% if actor.summary %}
|
|
28
|
+
<p class="actor-profile__summary">{{ actor.summary }}</p>
|
|
29
|
+
{% endif %}
|
|
30
|
+
<div class="actor-profile__stats">
|
|
31
|
+
{% if actor.followersCount %}
|
|
32
|
+
<span>{{ actor.followersCount }} followers</span>
|
|
33
|
+
{% endif %}
|
|
34
|
+
{% if actor.followingCount %}
|
|
35
|
+
<span>{{ actor.followingCount }} following</span>
|
|
36
|
+
{% endif %}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="actor-profile__actions">
|
|
41
|
+
<a href="{{ actor.url }}" class="button button--secondary button--small" target="_blank" rel="noopener">
|
|
42
|
+
{{ icon("external") }} View profile
|
|
43
|
+
</a>
|
|
44
|
+
{% if canFollow %}
|
|
45
|
+
{% if isFollowing %}
|
|
46
|
+
<form action="{{ baseUrl }}/actor/unfollow" method="POST" style="display: inline;">
|
|
47
|
+
<input type="hidden" name="actorUrl" value="{{ actorUrl }}">
|
|
48
|
+
<button type="submit" class="button button--secondary button--small">
|
|
49
|
+
{{ icon("checkboxChecked") }} Following
|
|
50
|
+
</button>
|
|
51
|
+
</form>
|
|
52
|
+
{% else %}
|
|
53
|
+
<form action="{{ baseUrl }}/actor/follow" method="POST" style="display: inline;">
|
|
54
|
+
<input type="hidden" name="actorUrl" value="{{ actorUrl }}">
|
|
55
|
+
<input type="hidden" name="actorName" value="{{ actor.name }}">
|
|
56
|
+
<button type="submit" class="button button--primary button--small">
|
|
57
|
+
{{ icon("syndicate") }} Follow
|
|
58
|
+
</button>
|
|
59
|
+
</form>
|
|
60
|
+
{% endif %}
|
|
61
|
+
{% endif %}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{% if error %}
|
|
66
|
+
<div class="reader__empty">
|
|
67
|
+
{{ icon("warning") }}
|
|
68
|
+
<p>{{ error }}</p>
|
|
69
|
+
</div>
|
|
70
|
+
{% elif items.length > 0 %}
|
|
71
|
+
<div class="timeline" id="timeline">
|
|
72
|
+
{% for item in items %}
|
|
73
|
+
<article class="item-card">
|
|
74
|
+
{# Author #}
|
|
75
|
+
{% if item.author %}
|
|
76
|
+
<div class="item-card__author" style="padding: 12px 16px 0;">
|
|
77
|
+
{% if item.author.photo %}
|
|
78
|
+
<img src="{{ item.author.photo }}"
|
|
79
|
+
alt=""
|
|
80
|
+
class="item-card__author-photo"
|
|
81
|
+
width="40"
|
|
82
|
+
height="40"
|
|
83
|
+
loading="lazy"
|
|
84
|
+
onerror="this.style.display='none'">
|
|
85
|
+
{% endif %}
|
|
86
|
+
<div class="item-card__author-info">
|
|
87
|
+
<span class="item-card__author-name">{{ item.author.name or "Unknown" }}</span>
|
|
88
|
+
{% if item.author.url %}
|
|
89
|
+
<span class="item-card__source">{{ item.author.url | replace("https://", "") | replace("http://", "") }}</span>
|
|
90
|
+
{% endif %}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
{% endif %}
|
|
94
|
+
|
|
95
|
+
<a href="{{ item.url }}" class="item-card__link" target="_blank" rel="noopener">
|
|
96
|
+
{# Reply context #}
|
|
97
|
+
{% if item["in-reply-to"] and item["in-reply-to"].length > 0 %}
|
|
98
|
+
<div class="item-card__context">
|
|
99
|
+
{{ icon("reply") }}
|
|
100
|
+
<span>Reply to</span>
|
|
101
|
+
<span>{{ item["in-reply-to"][0] | replace("https://", "") | replace("http://", "") | truncate(50) }}</span>
|
|
102
|
+
</div>
|
|
103
|
+
{% endif %}
|
|
104
|
+
|
|
105
|
+
{# Title #}
|
|
106
|
+
{% if item.name %}
|
|
107
|
+
<h3 class="item-card__title">{{ item.name }}</h3>
|
|
108
|
+
{% endif %}
|
|
109
|
+
|
|
110
|
+
{# Content #}
|
|
111
|
+
{% if item.content %}
|
|
112
|
+
<div class="item-card__content{% if (item.content.text or '') | length > 300 %} item-card__content--truncated{% endif %}">
|
|
113
|
+
{% if item.content.html %}
|
|
114
|
+
{{ item.content.html | safe | striptags | truncate(400) }}
|
|
115
|
+
{% elif item.content.text %}
|
|
116
|
+
{{ item.content.text | truncate(400) }}
|
|
117
|
+
{% endif %}
|
|
118
|
+
</div>
|
|
119
|
+
{% endif %}
|
|
120
|
+
|
|
121
|
+
{# Tags #}
|
|
122
|
+
{% if item.category and item.category.length > 0 %}
|
|
123
|
+
<div class="item-card__categories">
|
|
124
|
+
{% for cat in item.category | slice(0, 5) %}
|
|
125
|
+
<span class="item-card__category">#{{ cat }}</span>
|
|
126
|
+
{% endfor %}
|
|
127
|
+
</div>
|
|
128
|
+
{% endif %}
|
|
129
|
+
|
|
130
|
+
{# Photos #}
|
|
131
|
+
{% if item.photo and item.photo.length > 0 %}
|
|
132
|
+
{% set photoCount = item.photo.length if item.photo.length <= 4 else 4 %}
|
|
133
|
+
<div class="item-card__photos item-card__photos--{{ photoCount }}">
|
|
134
|
+
{% for photo in item.photo | slice(0, 4) %}
|
|
135
|
+
<img src="{{ photo }}" alt="" class="item-card__photo" loading="lazy"
|
|
136
|
+
onerror="this.parentElement.removeChild(this)">
|
|
137
|
+
{% endfor %}
|
|
138
|
+
</div>
|
|
139
|
+
{% endif %}
|
|
140
|
+
|
|
141
|
+
{# Footer #}
|
|
142
|
+
<footer class="item-card__footer">
|
|
143
|
+
{% if item.published %}
|
|
144
|
+
<time datetime="{{ item.published }}" class="item-card__date">
|
|
145
|
+
{{ item.published | date("PP", { locale: locale, timeZone: application.timeZone }) }}
|
|
146
|
+
</time>
|
|
147
|
+
{% endif %}
|
|
148
|
+
</footer>
|
|
149
|
+
</a>
|
|
150
|
+
|
|
151
|
+
{# Actions #}
|
|
152
|
+
<div class="item-actions">
|
|
153
|
+
<a href="{{ item.url }}" class="item-actions__button" target="_blank" rel="noopener" title="View original">
|
|
154
|
+
{{ icon("external") }}
|
|
155
|
+
</a>
|
|
156
|
+
<a href="{{ baseUrl }}/compose?reply={{ item.url | urlencode }}" class="item-actions__button" title="Reply">
|
|
157
|
+
{{ icon("reply") }}
|
|
158
|
+
</a>
|
|
159
|
+
<a href="{{ baseUrl }}/compose?like={{ item.url | urlencode }}" class="item-actions__button" title="Like">
|
|
160
|
+
{{ icon("like") }}
|
|
161
|
+
</a>
|
|
162
|
+
<a href="{{ baseUrl }}/compose?repost={{ item.url | urlencode }}" class="item-actions__button" title="Repost">
|
|
163
|
+
{{ icon("repost") }}
|
|
164
|
+
</a>
|
|
165
|
+
<a href="{{ baseUrl }}/compose?bookmark={{ item.url | urlencode }}" class="item-actions__button" title="Bookmark">
|
|
166
|
+
{{ icon("bookmark") }}
|
|
167
|
+
</a>
|
|
168
|
+
</div>
|
|
169
|
+
</article>
|
|
170
|
+
{% endfor %}
|
|
171
|
+
</div>
|
|
172
|
+
{% else %}
|
|
173
|
+
<div class="reader__empty">
|
|
174
|
+
{{ icon("syndicate") }}
|
|
175
|
+
<p>No posts found for this actor.</p>
|
|
176
|
+
</div>
|
|
177
|
+
{% endif %}
|
|
178
|
+
</div>
|
|
179
|
+
{% endblock %}
|
|
@@ -63,7 +63,12 @@
|
|
|
63
63
|
{% endif %}
|
|
64
64
|
<div class="item-card__author-info">
|
|
65
65
|
<span class="item-card__author-name">{{ item.author.name or "Unknown" }}</span>
|
|
66
|
-
{% if item._source %}
|
|
66
|
+
{% if item._source and item._source.type === "activitypub" %}
|
|
67
|
+
<span class="item-card__source">
|
|
68
|
+
<span class="item-card__badge item-card__badge--ap" title="Fediverse">AP</span>
|
|
69
|
+
{{ item.author.url | replace("https://", "") | replace("http://", "") }}
|
|
70
|
+
</span>
|
|
71
|
+
{% elif item._source %}
|
|
67
72
|
<span class="item-card__source">{{ item._source.name or item._source.url }}</span>
|
|
68
73
|
{% elif item.author.url %}
|
|
69
74
|
<span class="item-card__source">{{ item.author.url | replace("https://", "") | replace("http://", "") }}</span>
|
|
@@ -147,6 +152,12 @@
|
|
|
147
152
|
|
|
148
153
|
{# Inline actions (Aperture pattern) #}
|
|
149
154
|
<div class="item-actions">
|
|
155
|
+
{% if item._source and item._source.type === "activitypub" and item.author and item.author.url %}
|
|
156
|
+
<a href="{{ baseUrl }}/actor?url={{ item.author.url | urlencode }}" class="item-actions__button" title="View actor profile">
|
|
157
|
+
{{ icon("mention") }}
|
|
158
|
+
<span class="visually-hidden">Actor profile</span>
|
|
159
|
+
</a>
|
|
160
|
+
{% endif %}
|
|
150
161
|
{% if item.url %}
|
|
151
162
|
<a href="{{ item.url }}" class="item-actions__button" target="_blank" rel="noopener" title="View original">
|
|
152
163
|
{{ icon("external") }}
|