@rmdes/indiekit-endpoint-activitypub 2.4.0 → 2.4.2

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/index.js CHANGED
@@ -1000,18 +1000,27 @@ export default class ActivityPubEndpoint {
1000
1000
  );
1001
1001
  }
1002
1002
 
1003
- // Drop non-sparse indexes if they exist (created by earlier versions),
1004
- // then recreate with sparse:true so multiple null values are allowed.
1005
- this._collections.ap_muted.dropIndex("url_1").catch(() => {});
1006
- this._collections.ap_muted.dropIndex("keyword_1").catch(() => {});
1007
- this._collections.ap_muted.createIndex(
1008
- { url: 1 },
1009
- { unique: true, sparse: true, background: true },
1010
- );
1011
- this._collections.ap_muted.createIndex(
1012
- { keyword: 1 },
1013
- { unique: true, sparse: true, background: true },
1014
- );
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(() => {});
1015
1024
 
1016
1025
  this._collections.ap_blocked.createIndex(
1017
1026
  { url: 1 },
@@ -10,6 +10,7 @@ import {
10
10
  getBlockedUrls,
11
11
  getFilterMode,
12
12
  } from "../storage/moderation.js";
13
+ import { stripQuoteReferenceHtml } from "../og-unfurl.js";
13
14
 
14
15
  export function apiTimelineController(mountPath) {
15
16
  return async (request, response, next) => {
@@ -134,6 +135,14 @@ export function apiTimelineController(mountPath) {
134
135
  }
135
136
  }
136
137
 
138
+ // Strip "RE:" paragraphs from items that have quote embeds
139
+ for (const item of items) {
140
+ const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
141
+ if (item.quote && quoteRef && item.content?.html) {
142
+ item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
143
+ }
144
+ }
145
+
137
146
  const csrfToken = getToken(request.session);
138
147
 
139
148
  // Render each card server-side using the same Nunjucks template
@@ -1,9 +1,9 @@
1
1
  // Post detail controller — view individual AP posts/notes/articles
2
2
  import { Article, Note, Person, Service, Application } from "@fedify/fedify/vocab";
3
3
  import { getToken } from "../csrf.js";
4
- import { extractObjectData } from "../timeline-store.js";
4
+ import { extractObjectData, extractActorInfo } from "../timeline-store.js";
5
5
  import { getCached, setCache } from "../lookup-cache.js";
6
- import { fetchAndStoreQuote } from "../og-unfurl.js";
6
+ import { fetchAndStoreQuote, stripQuoteReferenceHtml } from "../og-unfurl.js";
7
7
 
8
8
  // Load parent posts (inReplyTo chain) up to maxDepth levels
9
9
  async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxDepth = 5) {
@@ -332,6 +332,20 @@ export function postDetailController(mountPath, plugin) {
332
332
 
333
333
  if (quoteObject) {
334
334
  const quoteData = await extractObjectData(quoteObject, { documentLoader: qLoader });
335
+
336
+ // If author photo is empty, try fetching the actor directly
337
+ if (!quoteData.author.photo && quoteData.author.url) {
338
+ try {
339
+ const actor = await qCtx.lookupObject(new URL(quoteData.author.url), { documentLoader: qLoader });
340
+ if (actor) {
341
+ const actorInfo = await extractActorInfo(actor, { documentLoader: qLoader });
342
+ if (actorInfo.photo) quoteData.author.photo = actorInfo.photo;
343
+ }
344
+ } catch {
345
+ // Actor fetch failed — keep existing author data
346
+ }
347
+ }
348
+
335
349
  timelineItem.quote = {
336
350
  url: quoteData.url || quoteData.uid,
337
351
  uid: quoteData.uid,
@@ -342,11 +356,24 @@ export function postDetailController(mountPath, plugin) {
342
356
  photo: quoteData.photo?.slice(0, 1) || [],
343
357
  };
344
358
 
359
+ // Strip RE: paragraph from parent content
360
+ const quoteRef = timelineItem.quoteUrl || timelineItem.quote.url || timelineItem.quote.uid;
361
+ if (timelineItem.content?.html && quoteRef) {
362
+ timelineItem.content.html = stripQuoteReferenceHtml(
363
+ timelineItem.content.html,
364
+ quoteRef,
365
+ );
366
+ }
367
+
345
368
  // Persist for future requests (fire-and-forget)
346
369
  if (timelineCol) {
370
+ const persistUpdate = { $set: { quote: timelineItem.quote } };
371
+ if (timelineItem.content?.html) {
372
+ persistUpdate.$set["content.html"] = timelineItem.content.html;
373
+ }
347
374
  timelineCol.updateOne(
348
375
  { $or: [{ uid: objectUrl }, { url: objectUrl }] },
349
- { $set: { quote: timelineItem.quote } },
376
+ persistUpdate,
350
377
  ).catch(() => {});
351
378
  }
352
379
  }
@@ -355,6 +382,14 @@ export function postDetailController(mountPath, plugin) {
355
382
  }
356
383
  }
357
384
 
385
+ // Strip RE: paragraph for items with existing quote data (render-time cleanup)
386
+ if (timelineItem.quote && timelineItem.content?.html) {
387
+ const quoteRef = timelineItem.quoteUrl || timelineItem.quote.url || timelineItem.quote.uid;
388
+ if (quoteRef) {
389
+ timelineItem.content.html = stripQuoteReferenceHtml(timelineItem.content.html, quoteRef);
390
+ }
391
+ }
392
+
358
393
  const csrfToken = getToken(request.session);
359
394
 
360
395
  response.render("activitypub-post-detail", {
@@ -19,6 +19,7 @@ import {
19
19
  getFilterMode,
20
20
  } from "../storage/moderation.js";
21
21
  import { getFollowedTags } from "../storage/followed-tags.js";
22
+ import { stripQuoteReferenceHtml } from "../og-unfurl.js";
22
23
 
23
24
  // Re-export controllers from split modules for backward compatibility
24
25
  export {
@@ -196,6 +197,14 @@ export function readerController(mountPath) {
196
197
  }
197
198
  }
198
199
 
200
+ // Strip "RE:" paragraphs from items that have quote embeds
201
+ for (const item of items) {
202
+ const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
203
+ if (item.quote && quoteRef && item.content?.html) {
204
+ item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
205
+ }
206
+ }
207
+
199
208
  // CSRF token for interaction forms
200
209
  const csrfToken = getToken(request.session);
201
210
 
package/lib/og-unfurl.js CHANGED
@@ -267,6 +267,22 @@ export async function fetchAndStoreQuote(collections, uid, quoteUrl, ctx, docume
267
267
 
268
268
  const quoteData = await extractObjectData(object, { documentLoader });
269
269
 
270
+ // If author photo is empty, try fetching the actor directly
271
+ if (!quoteData.author.photo && quoteData.author.url) {
272
+ try {
273
+ const actor = await ctx.lookupObject(new URL(quoteData.author.url), { documentLoader });
274
+ if (actor) {
275
+ const { extractActorInfo } = await import("./timeline-store.js");
276
+ const actorInfo = await extractActorInfo(actor, { documentLoader });
277
+ if (actorInfo.photo) {
278
+ quoteData.author.photo = actorInfo.photo;
279
+ }
280
+ }
281
+ } catch {
282
+ // Actor fetch failed — keep existing author data
283
+ }
284
+ }
285
+
270
286
  const quote = {
271
287
  url: quoteData.url || quoteData.uid,
272
288
  uid: quoteData.uid,
@@ -277,11 +293,46 @@ export async function fetchAndStoreQuote(collections, uid, quoteUrl, ctx, docume
277
293
  photo: quoteData.photo?.slice(0, 1) || [],
278
294
  };
279
295
 
280
- await collections.ap_timeline.updateOne(
281
- { uid },
282
- { $set: { quote } },
283
- );
296
+ // Strip the "RE: <link>" paragraph from the parent post's content
297
+ // Mastodon adds this as: <p>RE: <a href="QUOTE_URL">...</a></p>
298
+ const update = { $set: { quote } };
299
+ const parentItem = await collections.ap_timeline.findOne({ uid });
300
+ if (parentItem?.content?.html) {
301
+ const cleaned = stripQuoteReferenceHtml(parentItem.content.html, quoteUrl);
302
+ if (cleaned !== parentItem.content.html) {
303
+ update.$set["content.html"] = cleaned;
304
+ }
305
+ }
306
+
307
+ await collections.ap_timeline.updateOne({ uid }, update);
284
308
  } catch (error) {
285
309
  console.error(`[og-unfurl] Failed to fetch quote for ${uid}: ${error.message}`);
286
310
  }
287
311
  }
312
+
313
+ /**
314
+ * Strip the "RE: <link>" paragraph that Mastodon adds for quoted posts.
315
+ * Removes <p> elements containing "RE:" followed by a link to the quote URL.
316
+ * @param {string} html - Content HTML
317
+ * @param {string} quoteUrl - URL of the quoted post
318
+ * @returns {string} Cleaned HTML
319
+ */
320
+ export function stripQuoteReferenceHtml(html, quoteUrl) {
321
+ if (!html || !quoteUrl) return html;
322
+ // Match <p> containing "RE:" and a link whose href contains the quote domain+path
323
+ // Mastodon uses both /users/X/statuses/Y and /@X/Y URL formats
324
+ try {
325
+ const quoteUrlObj = new URL(quoteUrl);
326
+ const quoteDomain = quoteUrlObj.hostname;
327
+ // Escape special regex chars in domain
328
+ const domainEscaped = quoteDomain.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
329
+ // Match <p>RE: <a href="...DOMAIN...">...</a></p> (with optional whitespace)
330
+ const re = new RegExp(
331
+ `<p>\\s*RE:\\s*<a\\s[^>]*href="[^"]*${domainEscaped}[^"]*"[^>]*>.*?</a>\\s*</p>`,
332
+ "i",
333
+ );
334
+ return html.replace(re, "").trim();
335
+ } catch {
336
+ return html;
337
+ }
338
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
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",