@rmdes/indiekit-endpoint-activitypub 2.2.0 → 2.4.0

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.
@@ -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,177 @@
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
+
2403
+ /* ==========================================================================
2404
+ Quote Embeds
2405
+ ========================================================================== */
2406
+
2407
+ .ap-quote-embed {
2408
+ border: var(--border-width-thin) solid var(--color-outline);
2409
+ border-radius: var(--border-radius-small);
2410
+ margin-top: var(--space-s);
2411
+ overflow: hidden;
2412
+ }
2413
+
2414
+ .ap-quote-embed--pending {
2415
+ border-style: dashed;
2416
+ }
2417
+
2418
+ .ap-quote-embed__link {
2419
+ color: inherit;
2420
+ display: block;
2421
+ padding: var(--space-s) var(--space-m);
2422
+ text-decoration: none;
2423
+ }
2424
+
2425
+ .ap-quote-embed__link:hover {
2426
+ background: var(--color-offset);
2427
+ }
2428
+
2429
+ .ap-quote-embed__author {
2430
+ align-items: center;
2431
+ display: flex;
2432
+ gap: var(--space-xs);
2433
+ margin-bottom: var(--space-xs);
2434
+ }
2435
+
2436
+ .ap-quote-embed__avatar {
2437
+ border-radius: 50%;
2438
+ flex-shrink: 0;
2439
+ height: 24px;
2440
+ object-fit: cover;
2441
+ width: 24px;
2442
+ }
2443
+
2444
+ .ap-quote-embed__avatar--default {
2445
+ align-items: center;
2446
+ background: var(--color-offset);
2447
+ color: var(--color-on-offset);
2448
+ display: inline-flex;
2449
+ font-size: var(--font-size-xs);
2450
+ font-weight: var(--font-weight-bold);
2451
+ justify-content: center;
2452
+ }
2453
+
2454
+ .ap-quote-embed__author-info {
2455
+ flex: 1;
2456
+ min-width: 0;
2457
+ }
2458
+
2459
+ .ap-quote-embed__name {
2460
+ font-size: var(--font-size-s);
2461
+ font-weight: var(--font-weight-bold);
2462
+ overflow: hidden;
2463
+ text-overflow: ellipsis;
2464
+ white-space: nowrap;
2465
+ }
2466
+
2467
+ .ap-quote-embed__handle {
2468
+ color: var(--color-on-offset);
2469
+ font-size: var(--font-size-xs);
2470
+ overflow: hidden;
2471
+ text-overflow: ellipsis;
2472
+ white-space: nowrap;
2473
+ }
2474
+
2475
+ .ap-quote-embed__time {
2476
+ color: var(--color-on-offset);
2477
+ flex-shrink: 0;
2478
+ font-size: var(--font-size-xs);
2479
+ white-space: nowrap;
2480
+ }
2481
+
2482
+ .ap-quote-embed__title {
2483
+ font-size: var(--font-size-s);
2484
+ font-weight: var(--font-weight-bold);
2485
+ margin: 0 0 var(--space-xs);
2486
+ }
2487
+
2488
+ .ap-quote-embed__content {
2489
+ -webkit-box-orient: vertical;
2490
+ -webkit-line-clamp: 6;
2491
+ color: var(--color-on-background);
2492
+ display: -webkit-box;
2493
+ font-size: var(--font-size-s);
2494
+ line-height: 1.5;
2495
+ overflow: hidden;
2496
+ }
2497
+
2498
+ .ap-quote-embed__content p {
2499
+ margin: 0 0 var(--space-xs);
2500
+ }
2501
+
2502
+ .ap-quote-embed__content p:last-child {
2503
+ margin-bottom: 0;
2504
+ }
2505
+
2506
+ .ap-quote-embed__media {
2507
+ margin-top: var(--space-xs);
2508
+ }
2509
+
2510
+ .ap-quote-embed__photo {
2511
+ border-radius: var(--border-radius-small);
2512
+ max-height: 160px;
2513
+ max-width: 100%;
2514
+ object-fit: cover;
2515
+ }
2516
+
2346
2517
  /* Hashtag tab sources info line */
2347
2518
  .ap-hashtag-sources {
2348
2519
  color: var(--color-on-offset);
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));
@@ -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 options = { before, limit };
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
+ }
@@ -3,6 +3,7 @@ import { Article, Note, Person, Service, Application } from "@fedify/fedify/voca
3
3
  import { getToken } from "../csrf.js";
4
4
  import { extractObjectData } from "../timeline-store.js";
5
5
  import { getCached, setCache } from "../lookup-cache.js";
6
+ import { fetchAndStoreQuote } from "../og-unfurl.js";
6
7
 
7
8
  // Load parent posts (inReplyTo chain) up to maxDepth levels
8
9
  async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxDepth = 5) {
@@ -315,6 +316,45 @@ export function postDetailController(mountPath, plugin) {
315
316
  // Continue with empty thread
316
317
  }
317
318
 
319
+ // On-demand quote enrichment: if item has quoteUrl but no quote data yet
320
+ if (timelineItem.quoteUrl && !timelineItem.quote) {
321
+ try {
322
+ const handle = plugin.options.actor.handle;
323
+ const qCtx = plugin._federation.createContext(
324
+ new URL(plugin._publicationUrl),
325
+ { handle, publicationUrl: plugin._publicationUrl },
326
+ );
327
+ const qLoader = await qCtx.getDocumentLoader({ identifier: handle });
328
+
329
+ const quoteObject = await qCtx.lookupObject(new URL(timelineItem.quoteUrl), {
330
+ documentLoader: qLoader,
331
+ });
332
+
333
+ if (quoteObject) {
334
+ const quoteData = await extractObjectData(quoteObject, { documentLoader: qLoader });
335
+ timelineItem.quote = {
336
+ url: quoteData.url || quoteData.uid,
337
+ uid: quoteData.uid,
338
+ author: quoteData.author,
339
+ content: quoteData.content,
340
+ published: quoteData.published,
341
+ name: quoteData.name,
342
+ photo: quoteData.photo?.slice(0, 1) || [],
343
+ };
344
+
345
+ // Persist for future requests (fire-and-forget)
346
+ if (timelineCol) {
347
+ timelineCol.updateOne(
348
+ { $or: [{ uid: objectUrl }, { url: objectUrl }] },
349
+ { $set: { quote: timelineItem.quote } },
350
+ ).catch(() => {});
351
+ }
352
+ }
353
+ } catch (error) {
354
+ console.warn(`[post-detail] Quote fetch failed for ${objectUrl}:`, error.message);
355
+ }
356
+ }
357
+
318
358
  const csrfToken = getToken(request.session);
319
359
 
320
360
  response.render("activitypub-post-detail", {
@@ -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 getUnreadNotificationCount(collections);
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,
@@ -27,7 +27,7 @@ import { logActivity as logActivityShared } from "./activity-log.js";
27
27
  import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";
28
28
  import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
29
29
  import { addNotification } from "./storage/notifications.js";
30
- import { fetchAndStorePreviews } from "./og-unfurl.js";
30
+ import { fetchAndStorePreviews, fetchAndStoreQuote } from "./og-unfurl.js";
31
31
  import { getFollowedTags } from "./storage/followed-tags.js";
32
32
 
33
33
  /**
@@ -361,6 +361,14 @@ export function registerInboxListeners(inboxChain, options) {
361
361
  });
362
362
 
363
363
  await addTimelineItem(collections, timelineItem);
364
+
365
+ // Fire-and-forget quote enrichment for boosted posts
366
+ if (timelineItem.quoteUrl) {
367
+ fetchAndStoreQuote(collections, timelineItem.uid, timelineItem.quoteUrl, ctx, authLoader)
368
+ .catch((error) => {
369
+ console.error(`[inbox] Quote fetch failed for ${timelineItem.uid}:`, error.message);
370
+ });
371
+ }
364
372
  } catch (error) {
365
373
  // Remote object unreachable (timeout, Authorized Fetch, deleted, etc.) — skip
366
374
  const cause = error?.cause?.code || error?.message || "unknown";
@@ -489,6 +497,14 @@ export function registerInboxListeners(inboxChain, options) {
489
497
  console.error(`[inbox] OG unfurl failed for ${timelineItem.uid}:`, error);
490
498
  });
491
499
  }
500
+
501
+ // Fire-and-forget quote enrichment
502
+ if (timelineItem.quoteUrl) {
503
+ fetchAndStoreQuote(collections, timelineItem.uid, timelineItem.quoteUrl, ctx, authLoader)
504
+ .catch((error) => {
505
+ console.error(`[inbox] Quote fetch failed for ${timelineItem.uid}:`, error.message);
506
+ });
507
+ }
492
508
  } catch (error) {
493
509
  // Log extraction errors but don't fail the entire handler
494
510
  console.error("Failed to store timeline item:", error);
package/lib/og-unfurl.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { unfurl } from "unfurl.js";
7
+ import { extractObjectData } from "./timeline-store.js";
7
8
 
8
9
  const USER_AGENT =
9
10
  "Mozilla/5.0 (compatible; Indiekit/1.0; +https://getindiekit.com)";
@@ -248,3 +249,39 @@ export async function fetchAndStorePreviews(collections, uid, html) {
248
249
  );
249
250
  }
250
251
  }
252
+
253
+ /**
254
+ * Fetch a quoted post's data and store it on the timeline item.
255
+ * Fire-and-forget — caller does NOT await. Errors are caught and logged.
256
+ * @param {object} collections - MongoDB collections
257
+ * @param {string} uid - Timeline item UID (the quoting post)
258
+ * @param {string} quoteUrl - URL of the quoted post
259
+ * @param {object} ctx - Fedify context (for lookupObject)
260
+ * @param {object} documentLoader - Authenticated DocumentLoader
261
+ * @returns {Promise<void>}
262
+ */
263
+ export async function fetchAndStoreQuote(collections, uid, quoteUrl, ctx, documentLoader) {
264
+ try {
265
+ const object = await ctx.lookupObject(new URL(quoteUrl), { documentLoader });
266
+ if (!object) return;
267
+
268
+ const quoteData = await extractObjectData(object, { documentLoader });
269
+
270
+ const quote = {
271
+ url: quoteData.url || quoteData.uid,
272
+ uid: quoteData.uid,
273
+ author: quoteData.author,
274
+ content: quoteData.content,
275
+ published: quoteData.published,
276
+ name: quoteData.name,
277
+ photo: quoteData.photo?.slice(0, 1) || [],
278
+ };
279
+
280
+ await collections.ap_timeline.updateOne(
281
+ { uid },
282
+ { $set: { quote } },
283
+ );
284
+ } catch (error) {
285
+ console.error(`[og-unfurl] Failed to fetch quote for ${uid}: ${error.message}`);
286
+ }
287
+ }
@@ -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
+ }
@@ -243,6 +243,9 @@ export async function extractObjectData(object, options = {}) {
243
243
  // In-reply-to — Fedify uses replyTargetId (non-fetching)
244
244
  const inReplyTo = object.replyTargetId?.href || "";
245
245
 
246
+ // Quote URL — Fedify reads quoteUrl / _misskey_quote / quoteUri
247
+ const quoteUrl = object.quoteUrl?.href || "";
248
+
246
249
  // Build base timeline item
247
250
  const item = {
248
251
  uid,
@@ -260,6 +263,7 @@ export async function extractObjectData(object, options = {}) {
260
263
  video,
261
264
  audio,
262
265
  inReplyTo,
266
+ quoteUrl,
263
267
  createdAt: new Date().toISOString()
264
268
  };
265
269
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
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
- {# Timeline items #}
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" %}
@@ -91,6 +91,9 @@
91
91
  </div>
92
92
  {% endif %}
93
93
 
94
+ {# Quoted post embed #}
95
+ {% include "partials/ap-quote-embed.njk" %}
96
+
94
97
  {# Link previews #}
95
98
  {% include "partials/ap-link-preview.njk" %}
96
99
 
@@ -106,6 +109,9 @@
106
109
  </div>
107
110
  {% endif %}
108
111
 
112
+ {# Quoted post embed #}
113
+ {% include "partials/ap-quote-embed.njk" %}
114
+
109
115
  {# Link previews #}
110
116
  {% include "partials/ap-link-preview.njk" %}
111
117
 
@@ -0,0 +1,41 @@
1
+ {# Quoted post embed — renders when a post quotes another post #}
2
+ {% if item.quote %}
3
+ <div class="ap-quote-embed">
4
+ <a href="{{ mountPath }}/admin/reader/post?url={{ item.quote.uid | urlencode }}" class="ap-quote-embed__link">
5
+ <header class="ap-quote-embed__author">
6
+ {% if item.quote.author.photo %}
7
+ <img src="{{ item.quote.author.photo }}" alt="" class="ap-quote-embed__avatar" loading="lazy" crossorigin="anonymous">
8
+ {% else %}
9
+ <span class="ap-quote-embed__avatar ap-quote-embed__avatar--default">{{ item.quote.author.name[0] | upper if item.quote.author.name else "?" }}</span>
10
+ {% endif %}
11
+ <div class="ap-quote-embed__author-info">
12
+ <div class="ap-quote-embed__name">{{ item.quote.author.name or "Unknown" }}</div>
13
+ {% if item.quote.author.handle %}
14
+ <div class="ap-quote-embed__handle">{{ item.quote.author.handle }}</div>
15
+ {% endif %}
16
+ </div>
17
+ {% if item.quote.published %}
18
+ <time datetime="{{ item.quote.published }}" class="ap-quote-embed__time">{{ item.quote.published | date("PPp") }}</time>
19
+ {% endif %}
20
+ </header>
21
+ {% if item.quote.name %}
22
+ <p class="ap-quote-embed__title">{{ item.quote.name }}</p>
23
+ {% endif %}
24
+ {% if item.quote.content and item.quote.content.html %}
25
+ <div class="ap-quote-embed__content">{{ item.quote.content.html | safe }}</div>
26
+ {% endif %}
27
+ {% if item.quote.photo and item.quote.photo.length > 0 %}
28
+ <div class="ap-quote-embed__media">
29
+ <img src="{{ item.quote.photo[0] }}" alt="" loading="lazy" class="ap-quote-embed__photo">
30
+ </div>
31
+ {% endif %}
32
+ </a>
33
+ </div>
34
+ {% elif item.quoteUrl %}
35
+ {# Fallback: quote not yet fetched — show as styled link #}
36
+ <div class="ap-quote-embed ap-quote-embed--pending">
37
+ <a href="{{ mountPath }}/admin/reader/post?url={{ item.quoteUrl | urlencode }}" class="ap-quote-embed__link">
38
+ Quoted post: {{ item.quoteUrl }}
39
+ </a>
40
+ </div>
41
+ {% endif %}