@rmdes/indiekit-endpoint-activitypub 2.2.0 → 2.3.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.
package/assets/reader.css CHANGED
@@ -2343,6 +2343,120 @@
2343
2343
  visibility: hidden;
2344
2344
  }
2345
2345
 
2346
+ /* ==========================================================================
2347
+ Quote Embeds
2348
+ ========================================================================== */
2349
+
2350
+ .ap-quote-embed {
2351
+ border: var(--border-width-thin) solid var(--color-outline);
2352
+ border-radius: var(--border-radius-small);
2353
+ margin-top: var(--space-s);
2354
+ overflow: hidden;
2355
+ }
2356
+
2357
+ .ap-quote-embed--pending {
2358
+ border-style: dashed;
2359
+ }
2360
+
2361
+ .ap-quote-embed__link {
2362
+ color: inherit;
2363
+ display: block;
2364
+ padding: var(--space-s) var(--space-m);
2365
+ text-decoration: none;
2366
+ }
2367
+
2368
+ .ap-quote-embed__link:hover {
2369
+ background: var(--color-offset);
2370
+ }
2371
+
2372
+ .ap-quote-embed__author {
2373
+ align-items: center;
2374
+ display: flex;
2375
+ gap: var(--space-xs);
2376
+ margin-bottom: var(--space-xs);
2377
+ }
2378
+
2379
+ .ap-quote-embed__avatar {
2380
+ border-radius: 50%;
2381
+ flex-shrink: 0;
2382
+ height: 24px;
2383
+ object-fit: cover;
2384
+ width: 24px;
2385
+ }
2386
+
2387
+ .ap-quote-embed__avatar--default {
2388
+ align-items: center;
2389
+ background: var(--color-offset);
2390
+ color: var(--color-on-offset);
2391
+ display: inline-flex;
2392
+ font-size: var(--font-size-xs);
2393
+ font-weight: var(--font-weight-bold);
2394
+ justify-content: center;
2395
+ }
2396
+
2397
+ .ap-quote-embed__author-info {
2398
+ flex: 1;
2399
+ min-width: 0;
2400
+ }
2401
+
2402
+ .ap-quote-embed__name {
2403
+ font-size: var(--font-size-s);
2404
+ font-weight: var(--font-weight-bold);
2405
+ overflow: hidden;
2406
+ text-overflow: ellipsis;
2407
+ white-space: nowrap;
2408
+ }
2409
+
2410
+ .ap-quote-embed__handle {
2411
+ color: var(--color-on-offset);
2412
+ font-size: var(--font-size-xs);
2413
+ overflow: hidden;
2414
+ text-overflow: ellipsis;
2415
+ white-space: nowrap;
2416
+ }
2417
+
2418
+ .ap-quote-embed__time {
2419
+ color: var(--color-on-offset);
2420
+ flex-shrink: 0;
2421
+ font-size: var(--font-size-xs);
2422
+ white-space: nowrap;
2423
+ }
2424
+
2425
+ .ap-quote-embed__title {
2426
+ font-size: var(--font-size-s);
2427
+ font-weight: var(--font-weight-bold);
2428
+ margin: 0 0 var(--space-xs);
2429
+ }
2430
+
2431
+ .ap-quote-embed__content {
2432
+ -webkit-box-orient: vertical;
2433
+ -webkit-line-clamp: 6;
2434
+ color: var(--color-on-background);
2435
+ display: -webkit-box;
2436
+ font-size: var(--font-size-s);
2437
+ line-height: 1.5;
2438
+ overflow: hidden;
2439
+ }
2440
+
2441
+ .ap-quote-embed__content p {
2442
+ margin: 0 0 var(--space-xs);
2443
+ }
2444
+
2445
+ .ap-quote-embed__content p:last-child {
2446
+ margin-bottom: 0;
2447
+ }
2448
+
2449
+ .ap-quote-embed__media {
2450
+ margin-top: var(--space-xs);
2451
+ }
2452
+
2453
+ .ap-quote-embed__photo {
2454
+ border-radius: var(--border-radius-small);
2455
+ max-height: 160px;
2456
+ max-width: 100%;
2457
+ object-fit: cover;
2458
+ }
2459
+
2346
2460
  /* Hashtag tab sources info line */
2347
2461
  .ap-hashtag-sources {
2348
2462
  color: var(--color-on-offset);
@@ -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", {
@@ -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
+ }
@@ -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.3.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",
@@ -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 %}