@rmdes/indiekit-endpoint-activitypub 2.3.0 → 2.4.1
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-infinite-scroll.js +150 -0
- package/assets/reader.css +57 -0
- package/index.js +24 -13
- package/lib/controllers/api-timeline.js +67 -3
- package/lib/controllers/reader.js +12 -4
- package/lib/storage/timeline.js +58 -0
- package/package.json +1 -1
- package/views/activitypub-reader.njk +33 -8
- package/views/partials/ap-item-card.njk +1 -1
|
@@ -186,4 +186,154 @@ document.addEventListener("alpine:init", () => {
|
|
|
186
186
|
if (this.observer) this.observer.disconnect();
|
|
187
187
|
},
|
|
188
188
|
}));
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* New posts banner — polls for new items every 30s, shows "N new posts" banner.
|
|
192
|
+
*/
|
|
193
|
+
// eslint-disable-next-line no-undef
|
|
194
|
+
Alpine.data("apNewPostsBanner", () => ({
|
|
195
|
+
count: 0,
|
|
196
|
+
newest: null,
|
|
197
|
+
tab: "",
|
|
198
|
+
mountPath: "",
|
|
199
|
+
_interval: null,
|
|
200
|
+
|
|
201
|
+
init() {
|
|
202
|
+
const el = this.$el;
|
|
203
|
+
this.newest = el.dataset.newest || null;
|
|
204
|
+
this.tab = el.dataset.tab || "notes";
|
|
205
|
+
this.mountPath = el.dataset.mountPath || "";
|
|
206
|
+
|
|
207
|
+
if (!this.newest) return;
|
|
208
|
+
|
|
209
|
+
this._interval = setInterval(() => this.poll(), 30000);
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
async poll() {
|
|
213
|
+
if (!this.newest) return;
|
|
214
|
+
try {
|
|
215
|
+
const params = new URLSearchParams({ after: this.newest, tab: this.tab });
|
|
216
|
+
const res = await fetch(
|
|
217
|
+
`${this.mountPath}/admin/reader/api/timeline/count-new?${params}`,
|
|
218
|
+
{ headers: { Accept: "application/json" } },
|
|
219
|
+
);
|
|
220
|
+
if (!res.ok) return;
|
|
221
|
+
const data = await res.json();
|
|
222
|
+
this.count = data.count || 0;
|
|
223
|
+
} catch {
|
|
224
|
+
// Silently ignore polling errors
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
async loadNew() {
|
|
229
|
+
if (!this.newest || this.count === 0) return;
|
|
230
|
+
try {
|
|
231
|
+
const params = new URLSearchParams({ after: this.newest, tab: this.tab });
|
|
232
|
+
const res = await fetch(
|
|
233
|
+
`${this.mountPath}/admin/reader/api/timeline?${params}`,
|
|
234
|
+
{ headers: { Accept: "application/json" } },
|
|
235
|
+
);
|
|
236
|
+
if (!res.ok) return;
|
|
237
|
+
const data = await res.json();
|
|
238
|
+
|
|
239
|
+
const timeline = document.getElementById("ap-timeline");
|
|
240
|
+
if (data.html && timeline) {
|
|
241
|
+
timeline.insertAdjacentHTML("afterbegin", data.html);
|
|
242
|
+
// Update newest cursor to the first item's published date
|
|
243
|
+
const firstCard = timeline.querySelector(".ap-card");
|
|
244
|
+
if (firstCard) {
|
|
245
|
+
const timeEl = firstCard.querySelector("time[datetime]");
|
|
246
|
+
if (timeEl) this.newest = timeEl.getAttribute("datetime");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
this.count = 0;
|
|
251
|
+
} catch {
|
|
252
|
+
// Silently ignore load errors
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
destroy() {
|
|
257
|
+
if (this._interval) clearInterval(this._interval);
|
|
258
|
+
},
|
|
259
|
+
}));
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Read tracking — IntersectionObserver marks cards as read on 50% visibility.
|
|
263
|
+
* Batches UIDs and flushes to server every 5 seconds.
|
|
264
|
+
*/
|
|
265
|
+
// eslint-disable-next-line no-undef
|
|
266
|
+
Alpine.data("apReadTracker", () => ({
|
|
267
|
+
_observer: null,
|
|
268
|
+
_batch: [],
|
|
269
|
+
_flushTimer: null,
|
|
270
|
+
_mountPath: "",
|
|
271
|
+
_csrfToken: "",
|
|
272
|
+
|
|
273
|
+
init() {
|
|
274
|
+
const el = this.$el;
|
|
275
|
+
this._mountPath = el.dataset.mountPath || "";
|
|
276
|
+
this._csrfToken = el.dataset.csrfToken || "";
|
|
277
|
+
|
|
278
|
+
this._observer = new IntersectionObserver(
|
|
279
|
+
(entries) => {
|
|
280
|
+
for (const entry of entries) {
|
|
281
|
+
if (entry.isIntersecting) {
|
|
282
|
+
const card = entry.target;
|
|
283
|
+
const uid = card.dataset.uid;
|
|
284
|
+
if (uid && !card.classList.contains("ap-card--read")) {
|
|
285
|
+
card.classList.add("ap-card--read");
|
|
286
|
+
this._batch.push(uid);
|
|
287
|
+
}
|
|
288
|
+
this._observer.unobserve(card);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
{ threshold: 0.5 },
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// Observe all existing cards
|
|
296
|
+
this._observeCards();
|
|
297
|
+
|
|
298
|
+
// Watch for new cards added by infinite scroll
|
|
299
|
+
this._mutationObserver = new MutationObserver(() => this._observeCards());
|
|
300
|
+
this._mutationObserver.observe(el, { childList: true, subtree: true });
|
|
301
|
+
|
|
302
|
+
// Flush batch every 5 seconds
|
|
303
|
+
this._flushTimer = setInterval(() => this._flush(), 5000);
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
_observeCards() {
|
|
307
|
+
const cards = this.$el.querySelectorAll(".ap-card[data-uid]:not(.ap-card--read)");
|
|
308
|
+
for (const card of cards) {
|
|
309
|
+
this._observer.observe(card);
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
async _flush() {
|
|
314
|
+
if (this._batch.length === 0) return;
|
|
315
|
+
const uids = [...this._batch];
|
|
316
|
+
this._batch = [];
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
await fetch(`${this._mountPath}/admin/reader/api/timeline/mark-read`, {
|
|
320
|
+
method: "POST",
|
|
321
|
+
headers: {
|
|
322
|
+
"Content-Type": "application/json",
|
|
323
|
+
"X-CSRF-Token": this._csrfToken,
|
|
324
|
+
},
|
|
325
|
+
body: JSON.stringify({ uids }),
|
|
326
|
+
});
|
|
327
|
+
} catch {
|
|
328
|
+
// Non-critical — items will be re-marked on next view
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
destroy() {
|
|
333
|
+
if (this._observer) this._observer.disconnect();
|
|
334
|
+
if (this._mutationObserver) this._mutationObserver.disconnect();
|
|
335
|
+
if (this._flushTimer) clearInterval(this._flushTimer);
|
|
336
|
+
this._flush(); // Final flush on teardown
|
|
337
|
+
},
|
|
338
|
+
}));
|
|
189
339
|
});
|
package/assets/reader.css
CHANGED
|
@@ -2343,6 +2343,63 @@
|
|
|
2343
2343
|
visibility: hidden;
|
|
2344
2344
|
}
|
|
2345
2345
|
|
|
2346
|
+
/* ==========================================================================
|
|
2347
|
+
New Posts Banner
|
|
2348
|
+
========================================================================== */
|
|
2349
|
+
|
|
2350
|
+
.ap-new-posts-banner {
|
|
2351
|
+
left: 0;
|
|
2352
|
+
position: sticky;
|
|
2353
|
+
right: 0;
|
|
2354
|
+
top: 0;
|
|
2355
|
+
z-index: 10;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
.ap-new-posts-banner__btn {
|
|
2359
|
+
background: var(--color-primary);
|
|
2360
|
+
border: none;
|
|
2361
|
+
border-radius: var(--border-radius-small);
|
|
2362
|
+
color: var(--color-on-primary);
|
|
2363
|
+
cursor: pointer;
|
|
2364
|
+
display: block;
|
|
2365
|
+
font-family: inherit;
|
|
2366
|
+
font-size: var(--font-size-s);
|
|
2367
|
+
margin: 0 auto var(--space-s);
|
|
2368
|
+
padding: var(--space-xs) var(--space-m);
|
|
2369
|
+
text-align: center;
|
|
2370
|
+
width: auto;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
.ap-new-posts-banner__btn:hover {
|
|
2374
|
+
opacity: 0.9;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
/* ==========================================================================
|
|
2378
|
+
Read State
|
|
2379
|
+
========================================================================== */
|
|
2380
|
+
|
|
2381
|
+
.ap-card--read {
|
|
2382
|
+
opacity: 0.7;
|
|
2383
|
+
transition: opacity 0.3s ease;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
.ap-card--read:hover {
|
|
2387
|
+
opacity: 1;
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
/* ==========================================================================
|
|
2391
|
+
Unread Toggle
|
|
2392
|
+
========================================================================== */
|
|
2393
|
+
|
|
2394
|
+
.ap-unread-toggle {
|
|
2395
|
+
margin-left: auto;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
.ap-unread-toggle--active {
|
|
2399
|
+
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
2400
|
+
font-weight: var(--font-weight-bold);
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2346
2403
|
/* ==========================================================================
|
|
2347
2404
|
Quote Embeds
|
|
2348
2405
|
========================================================================== */
|
package/index.js
CHANGED
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
} from "./lib/controllers/featured-tags.js";
|
|
62
62
|
import { resolveController } from "./lib/controllers/resolve.js";
|
|
63
63
|
import { tagTimelineController } from "./lib/controllers/tag-timeline.js";
|
|
64
|
-
import { apiTimelineController } from "./lib/controllers/api-timeline.js";
|
|
64
|
+
import { apiTimelineController, countNewController, markReadController } from "./lib/controllers/api-timeline.js";
|
|
65
65
|
import {
|
|
66
66
|
exploreController,
|
|
67
67
|
exploreApiController,
|
|
@@ -239,6 +239,8 @@ export default class ActivityPubEndpoint {
|
|
|
239
239
|
router.get("/admin/reader", readerController(mp));
|
|
240
240
|
router.get("/admin/reader/tag", tagTimelineController(mp));
|
|
241
241
|
router.get("/admin/reader/api/timeline", apiTimelineController(mp));
|
|
242
|
+
router.get("/admin/reader/api/timeline/count-new", countNewController());
|
|
243
|
+
router.post("/admin/reader/api/timeline/mark-read", markReadController());
|
|
242
244
|
router.get("/admin/reader/explore", exploreController(mp));
|
|
243
245
|
router.get("/admin/reader/api/explore", exploreApiController(mp));
|
|
244
246
|
router.get("/admin/reader/api/explore/hashtag", hashtagExploreApiController(mp));
|
|
@@ -998,18 +1000,27 @@ export default class ActivityPubEndpoint {
|
|
|
998
1000
|
);
|
|
999
1001
|
}
|
|
1000
1002
|
|
|
1001
|
-
//
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1003
|
+
// Muted collection — sparse unique indexes (allow multiple null values)
|
|
1004
|
+
this._collections.ap_muted
|
|
1005
|
+
.dropIndex("url_1")
|
|
1006
|
+
.catch(() => {})
|
|
1007
|
+
.then(() =>
|
|
1008
|
+
this._collections.ap_muted.createIndex(
|
|
1009
|
+
{ url: 1 },
|
|
1010
|
+
{ unique: true, sparse: true, background: true },
|
|
1011
|
+
),
|
|
1012
|
+
)
|
|
1013
|
+
.catch(() => {});
|
|
1014
|
+
this._collections.ap_muted
|
|
1015
|
+
.dropIndex("keyword_1")
|
|
1016
|
+
.catch(() => {})
|
|
1017
|
+
.then(() =>
|
|
1018
|
+
this._collections.ap_muted.createIndex(
|
|
1019
|
+
{ keyword: 1 },
|
|
1020
|
+
{ unique: true, sparse: true, background: true },
|
|
1021
|
+
),
|
|
1022
|
+
)
|
|
1023
|
+
.catch(() => {});
|
|
1013
1024
|
|
|
1014
1025
|
this._collections.ap_blocked.createIndex(
|
|
1015
1026
|
{ url: 1 },
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* JSON API timeline endpoint — returns pre-rendered HTML cards for infinite scroll AJAX loads.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { getTimelineItems } from "../storage/timeline.js";
|
|
6
|
-
import { getToken } from "../csrf.js";
|
|
5
|
+
import { getTimelineItems, countNewItems, markItemsRead } from "../storage/timeline.js";
|
|
6
|
+
import { getToken, validateToken } from "../csrf.js";
|
|
7
7
|
import {
|
|
8
8
|
getMutedUrls,
|
|
9
9
|
getMutedKeywords,
|
|
@@ -26,7 +26,8 @@ export function apiTimelineController(mountPath) {
|
|
|
26
26
|
const limit = 20;
|
|
27
27
|
|
|
28
28
|
// Build storage query options (same logic as readerController)
|
|
29
|
-
const
|
|
29
|
+
const unread = request.query.unread === "1";
|
|
30
|
+
const options = { before, limit, unread };
|
|
30
31
|
|
|
31
32
|
if (tag) {
|
|
32
33
|
options.tag = tag;
|
|
@@ -168,3 +169,66 @@ export function apiTimelineController(mountPath) {
|
|
|
168
169
|
}
|
|
169
170
|
};
|
|
170
171
|
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* GET /admin/reader/api/timeline/count-new — count items newer than a given date.
|
|
175
|
+
*/
|
|
176
|
+
export function countNewController() {
|
|
177
|
+
return async (request, response, next) => {
|
|
178
|
+
try {
|
|
179
|
+
const { application } = request.app.locals;
|
|
180
|
+
const collections = {
|
|
181
|
+
ap_timeline: application?.collections?.get("ap_timeline"),
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const after = request.query.after;
|
|
185
|
+
const tab = request.query.tab || "notes";
|
|
186
|
+
|
|
187
|
+
const options = {};
|
|
188
|
+
if (tab === "notes") {
|
|
189
|
+
options.type = "note";
|
|
190
|
+
options.excludeReplies = true;
|
|
191
|
+
} else if (tab === "articles") {
|
|
192
|
+
options.type = "article";
|
|
193
|
+
} else if (tab === "boosts") {
|
|
194
|
+
options.type = "boost";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const count = await countNewItems(collections, after, options);
|
|
198
|
+
response.json({ count });
|
|
199
|
+
} catch (error) {
|
|
200
|
+
next(error);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* POST /admin/reader/api/timeline/mark-read — mark items as read by UID array.
|
|
207
|
+
*/
|
|
208
|
+
export function markReadController() {
|
|
209
|
+
return async (request, response, next) => {
|
|
210
|
+
try {
|
|
211
|
+
if (!validateToken(request)) {
|
|
212
|
+
return response.status(403).json({ success: false, error: "Invalid CSRF token" });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const { uids } = request.body;
|
|
216
|
+
if (!Array.isArray(uids) || uids.length === 0) {
|
|
217
|
+
return response.status(400).json({ success: false, error: "Missing uids array" });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Cap batch size to prevent abuse
|
|
221
|
+
const batch = uids.slice(0, 100).filter((uid) => typeof uid === "string");
|
|
222
|
+
|
|
223
|
+
const { application } = request.app.locals;
|
|
224
|
+
const collections = {
|
|
225
|
+
ap_timeline: application?.collections?.get("ap_timeline"),
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const updated = await markItemsRead(collections, batch);
|
|
229
|
+
response.json({ success: true, updated });
|
|
230
|
+
} catch (error) {
|
|
231
|
+
next(error);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Reader controller — shows timeline of posts from followed accounts.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { getTimelineItems } from "../storage/timeline.js";
|
|
5
|
+
import { getTimelineItems, countUnreadItems } from "../storage/timeline.js";
|
|
6
6
|
import {
|
|
7
7
|
getNotifications,
|
|
8
8
|
getUnreadNotificationCount,
|
|
@@ -48,8 +48,11 @@ export function readerController(mountPath) {
|
|
|
48
48
|
const after = request.query.after;
|
|
49
49
|
const limit = Number.parseInt(request.query.limit || "20", 10);
|
|
50
50
|
|
|
51
|
+
// Unread filter
|
|
52
|
+
const unread = request.query.unread === "1";
|
|
53
|
+
|
|
51
54
|
// Build query options
|
|
52
|
-
const options = { before, after, limit };
|
|
55
|
+
const options = { before, after, limit, unread };
|
|
53
56
|
|
|
54
57
|
// Tab filtering
|
|
55
58
|
if (tab === "notes") {
|
|
@@ -141,8 +144,11 @@ export function readerController(mountPath) {
|
|
|
141
144
|
});
|
|
142
145
|
}
|
|
143
146
|
|
|
144
|
-
// Get unread notification count for badge
|
|
145
|
-
const unreadCount = await
|
|
147
|
+
// Get unread notification count for badge + unread timeline count for toggle
|
|
148
|
+
const [unreadCount, unreadTimelineCount] = await Promise.all([
|
|
149
|
+
getUnreadNotificationCount(collections),
|
|
150
|
+
countUnreadItems(collections),
|
|
151
|
+
]);
|
|
146
152
|
|
|
147
153
|
// Get interaction state for liked/boosted indicators
|
|
148
154
|
// Interactions are keyed by canonical AP uid (new) or display url (legacy).
|
|
@@ -206,9 +212,11 @@ export function readerController(mountPath) {
|
|
|
206
212
|
readerParent: { href: mountPath, text: response.locals.__("activitypub.title") },
|
|
207
213
|
items,
|
|
208
214
|
tab,
|
|
215
|
+
unread,
|
|
209
216
|
before: result.before,
|
|
210
217
|
after: result.after,
|
|
211
218
|
unreadCount,
|
|
219
|
+
unreadTimelineCount,
|
|
212
220
|
interactionMap,
|
|
213
221
|
csrfToken,
|
|
214
222
|
mountPath,
|
package/lib/storage/timeline.js
CHANGED
|
@@ -107,6 +107,11 @@ export async function getTimelineItems(collections, options = {}) {
|
|
|
107
107
|
query.category = { $regex: new RegExp(`^${escapedTag}$`, "i") };
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// Unread-only filter
|
|
111
|
+
if (options.unread) {
|
|
112
|
+
query.read = { $ne: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
110
115
|
// Cursor pagination — published is stored as ISO string, so compare
|
|
111
116
|
// as strings (lexicographic ISO 8601 comparison is correct for dates)
|
|
112
117
|
if (options.before) {
|
|
@@ -233,3 +238,56 @@ export async function cleanupTimelineByCount(collections, keepCount) {
|
|
|
233
238
|
const cutoffDate = items[0].published;
|
|
234
239
|
return await deleteOldTimelineItems(collections, cutoffDate);
|
|
235
240
|
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Count timeline items newer than a given date
|
|
244
|
+
* @param {object} collections - MongoDB collections
|
|
245
|
+
* @param {string} after - ISO date string — count items published after this
|
|
246
|
+
* @param {object} [options] - Filter options
|
|
247
|
+
* @param {string} [options.type] - Filter by type
|
|
248
|
+
* @param {boolean} [options.excludeReplies] - Exclude replies
|
|
249
|
+
* @returns {Promise<number>} Count of new items
|
|
250
|
+
*/
|
|
251
|
+
export async function countNewItems(collections, after, options = {}) {
|
|
252
|
+
const { ap_timeline } = collections;
|
|
253
|
+
if (!after || Number.isNaN(new Date(after).getTime())) return 0;
|
|
254
|
+
|
|
255
|
+
const query = { published: { $gt: after } };
|
|
256
|
+
if (options.type) query.type = options.type;
|
|
257
|
+
if (options.excludeReplies) {
|
|
258
|
+
query.$or = [
|
|
259
|
+
{ inReplyTo: null },
|
|
260
|
+
{ inReplyTo: "" },
|
|
261
|
+
{ inReplyTo: { $exists: false } },
|
|
262
|
+
];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return await ap_timeline.countDocuments(query);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Mark timeline items as read
|
|
270
|
+
* @param {object} collections - MongoDB collections
|
|
271
|
+
* @param {string[]} uids - Array of item UIDs to mark as read
|
|
272
|
+
* @returns {Promise<number>} Number of items updated
|
|
273
|
+
*/
|
|
274
|
+
export async function markItemsRead(collections, uids) {
|
|
275
|
+
const { ap_timeline } = collections;
|
|
276
|
+
if (!uids || uids.length === 0) return 0;
|
|
277
|
+
|
|
278
|
+
const result = await ap_timeline.updateMany(
|
|
279
|
+
{ uid: { $in: uids }, read: { $ne: true } },
|
|
280
|
+
{ $set: { read: true } },
|
|
281
|
+
);
|
|
282
|
+
return result.modifiedCount;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Count unread timeline items
|
|
287
|
+
* @param {object} collections - MongoDB collections
|
|
288
|
+
* @returns {Promise<number>}
|
|
289
|
+
*/
|
|
290
|
+
export async function countUnreadItems(collections) {
|
|
291
|
+
const { ap_timeline } = collections;
|
|
292
|
+
return await ap_timeline.countDocuments({ read: { $ne: true } });
|
|
293
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.1",
|
|
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",
|
|
@@ -64,32 +64,57 @@
|
|
|
64
64
|
|
|
65
65
|
{# Tab navigation #}
|
|
66
66
|
<nav class="ap-tabs" role="tablist">
|
|
67
|
-
<a href="?tab=notes" class="ap-tab{% if tab == 'notes' %} ap-tab--active{% endif %}" role="tab">
|
|
67
|
+
<a href="?tab=notes{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'notes' %} ap-tab--active{% endif %}" role="tab">
|
|
68
68
|
{{ __("activitypub.reader.tabs.notes") }}
|
|
69
69
|
</a>
|
|
70
|
-
<a href="?tab=articles" class="ap-tab{% if tab == 'articles' %} ap-tab--active{% endif %}" role="tab">
|
|
70
|
+
<a href="?tab=articles{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'articles' %} ap-tab--active{% endif %}" role="tab">
|
|
71
71
|
{{ __("activitypub.reader.tabs.articles") }}
|
|
72
72
|
</a>
|
|
73
|
-
<a href="?tab=replies" class="ap-tab{% if tab == 'replies' %} ap-tab--active{% endif %}" role="tab">
|
|
73
|
+
<a href="?tab=replies{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'replies' %} ap-tab--active{% endif %}" role="tab">
|
|
74
74
|
{{ __("activitypub.reader.tabs.replies") }}
|
|
75
75
|
</a>
|
|
76
|
-
<a href="?tab=boosts" class="ap-tab{% if tab == 'boosts' %} ap-tab--active{% endif %}" role="tab">
|
|
76
|
+
<a href="?tab=boosts{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'boosts' %} ap-tab--active{% endif %}" role="tab">
|
|
77
77
|
{{ __("activitypub.reader.tabs.boosts") }}
|
|
78
78
|
</a>
|
|
79
|
-
<a href="?tab=media" class="ap-tab{% if tab == 'media' %} ap-tab--active{% endif %}" role="tab">
|
|
79
|
+
<a href="?tab=media{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'media' %} ap-tab--active{% endif %}" role="tab">
|
|
80
80
|
{{ __("activitypub.reader.tabs.media") }}
|
|
81
81
|
</a>
|
|
82
|
-
<a href="?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
|
|
82
|
+
<a href="?tab=all{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
|
|
83
83
|
{{ __("activitypub.reader.tabs.all") }}
|
|
84
84
|
</a>
|
|
85
|
+
<a href="?tab={{ tab }}{% if not unread %}&unread=1{% endif %}" class="ap-tab ap-unread-toggle{% if unread %} ap-unread-toggle--active{% endif %}" title="{% if unread %}Show all posts{% else %}Show unread only{% endif %}">
|
|
86
|
+
{% if unread %}
|
|
87
|
+
All posts
|
|
88
|
+
{% else %}
|
|
89
|
+
Unread{% if unreadTimelineCount %} ({{ unreadTimelineCount }}){% endif %}
|
|
90
|
+
{% endif %}
|
|
91
|
+
</a>
|
|
85
92
|
</nav>
|
|
86
93
|
|
|
87
|
-
{#
|
|
94
|
+
{# New posts banner — polls every 30s, shows count of new items #}
|
|
95
|
+
{% if items.length > 0 %}
|
|
96
|
+
<div class="ap-new-posts-banner"
|
|
97
|
+
x-data="apNewPostsBanner()"
|
|
98
|
+
data-newest="{{ items[0].published }}"
|
|
99
|
+
data-tab="{{ tab }}"
|
|
100
|
+
data-mount-path="{{ mountPath }}"
|
|
101
|
+
x-show="count > 0"
|
|
102
|
+
x-cloak>
|
|
103
|
+
<button class="ap-new-posts-banner__btn" @click="loadNew()">
|
|
104
|
+
<span x-text="count + ' new post' + (count !== 1 ? 's' : '')"></span> — Load
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
{% endif %}
|
|
108
|
+
|
|
109
|
+
{# Timeline items with read tracking #}
|
|
88
110
|
{% if items.length > 0 %}
|
|
89
111
|
<div class="ap-timeline"
|
|
90
112
|
id="ap-timeline"
|
|
91
113
|
data-mount-path="{{ mountPath }}"
|
|
92
|
-
data-before="{{ before if before else '' }}"
|
|
114
|
+
data-before="{{ before if before else '' }}"
|
|
115
|
+
data-csrf-token="{{ csrfToken }}"
|
|
116
|
+
x-data="apReadTracker()"
|
|
117
|
+
x-init="init()">
|
|
93
118
|
{% for item in items %}
|
|
94
119
|
{% include "partials/ap-item-card.njk" %}
|
|
95
120
|
{% endfor %}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{% set hasCardMedia = (item.photo and item.photo.length > 0) or (item.video and item.video.length > 0) or (item.audio and item.audio.length > 0) %}
|
|
7
7
|
{% if hasCardContent or hasCardTitle or hasCardMedia %}
|
|
8
8
|
|
|
9
|
-
<article class="ap-card{% if item.type %} ap-card--{{ item.type }}{% endif %}{% if item.inReplyTo %} ap-card--reply{% endif %}{% if item._moderated %} ap-card--moderated{% endif %}">
|
|
9
|
+
<article class="ap-card{% if item.type %} ap-card--{{ item.type }}{% endif %}{% if item.inReplyTo %} ap-card--reply{% endif %}{% if item._moderated %} ap-card--moderated{% endif %}{% if item.read %} ap-card--read{% endif %}" data-uid="{{ item.uid }}">
|
|
10
10
|
{# Moderation content warning wrapper #}
|
|
11
11
|
{% if item._moderated %}
|
|
12
12
|
{% if item._moderationReason == "muted_account" %}
|