@rmdes/indiekit-endpoint-activitypub 2.5.5 → 2.6.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
@@ -301,6 +301,21 @@
301
301
  text-decoration: underline;
302
302
  }
303
303
 
304
+ .ap-card__bot-badge {
305
+ display: inline-block;
306
+ font-size: 0.6rem;
307
+ font-weight: 700;
308
+ line-height: 1;
309
+ padding: 0.15em 0.35em;
310
+ margin-left: 0.3em;
311
+ border: var(--border-width-thin) solid var(--color-on-offset);
312
+ border-radius: var(--border-radius-small);
313
+ color: var(--color-on-offset);
314
+ vertical-align: middle;
315
+ text-transform: uppercase;
316
+ letter-spacing: 0.03em;
317
+ }
318
+
304
319
  .ap-card__author-handle {
305
320
  color: var(--color-on-offset);
306
321
  font-size: var(--font-size-s);
@@ -315,9 +330,17 @@
315
330
  font-size: var(--font-size-xs);
316
331
  }
317
332
 
333
+ .ap-card__edited {
334
+ font-size: var(--font-size-xs);
335
+ margin-left: 0.2em;
336
+ }
337
+
318
338
  .ap-card__timestamp-link {
319
339
  color: inherit;
320
340
  text-decoration: none;
341
+ display: flex;
342
+ align-items: center;
343
+ gap: 0;
321
344
  }
322
345
 
323
346
  .ap-card__timestamp-link:hover {
@@ -706,6 +729,30 @@
706
729
  opacity: 0.7;
707
730
  }
708
731
 
732
+ /* Hashtag stuffing collapse */
733
+ .ap-hashtag-overflow {
734
+ margin: var(--space-xs) 0;
735
+ font-size: var(--font-size-s);
736
+ }
737
+
738
+ .ap-hashtag-overflow summary {
739
+ cursor: pointer;
740
+ color: var(--color-on-offset);
741
+ list-style: none;
742
+ }
743
+
744
+ .ap-hashtag-overflow summary::before {
745
+ content: "▸ ";
746
+ }
747
+
748
+ .ap-hashtag-overflow[open] summary::before {
749
+ content: "▾ ";
750
+ }
751
+
752
+ .ap-hashtag-overflow p {
753
+ margin-top: var(--space-xs);
754
+ }
755
+
709
756
  /* ==========================================================================
710
757
  Interaction Buttons
711
758
  ========================================================================== */
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Content post-processing utilities.
3
+ * Applied after sanitization and emoji replacement in the item pipeline.
4
+ */
5
+
6
+ /**
7
+ * Shorten displayed URLs in <a> tags that exceed maxLength.
8
+ * Keeps the full URL in href, only truncates the visible text.
9
+ *
10
+ * Example: <a href="https://example.com/very/long/path">https://example.com/very/long/path</a>
11
+ * → <a href="https://example.com/very/long/path" title="https://example.com/very/long/path">example.com/very/lon…</a>
12
+ *
13
+ * @param {string} html - Sanitized HTML content
14
+ * @param {number} [maxLength=30] - Max visible URL length before truncation
15
+ * @returns {string} HTML with shortened display URLs
16
+ */
17
+ export function shortenDisplayUrls(html, maxLength = 30) {
18
+ if (!html) return html;
19
+
20
+ // Match <a ...>URL text</a> where the visible text looks like a URL
21
+ return html.replace(
22
+ /(<a\s[^>]*>)(https?:\/\/[^<]+)(<\/a>)/gi,
23
+ (match, openTag, urlText, closeTag) => {
24
+ if (urlText.length <= maxLength) return match;
25
+
26
+ // Strip protocol for display
27
+ const display = urlText.replace(/^https?:\/\//, "");
28
+ const truncated = display.slice(0, maxLength - 1) + "\u2026";
29
+
30
+ // Add title attribute with full URL for hover tooltip (if not already present)
31
+ let tag = openTag;
32
+ if (!tag.includes("title=")) {
33
+ tag = tag.replace(/>$/, ` title="${urlText}">`);
34
+ }
35
+
36
+ return `${tag}${truncated}${closeTag}`;
37
+ },
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Collapse paragraphs that are mostly hashtag links (hashtag stuffing).
43
+ * Detects <p> blocks where 80%+ of the text content is hashtag links
44
+ * and wraps them in a <details> element.
45
+ *
46
+ * @param {string} html - Sanitized HTML content
47
+ * @param {number} [minTags=3] - Minimum number of hashtag links to trigger collapse
48
+ * @returns {string} HTML with hashtag-heavy paragraphs collapsed
49
+ */
50
+ export function collapseHashtagStuffing(html, minTags = 3) {
51
+ if (!html) return html;
52
+
53
+ // Match <p> blocks
54
+ return html.replace(/<p>([^]*?)<\/p>/gi, (match, inner) => {
55
+ // Count hashtag links: <a ...>#something</a> or plain #word
56
+ const hashtagLinks = inner.match(/<a[^>]*>#[^<]+<\/a>/gi) || [];
57
+ if (hashtagLinks.length < minTags) return match;
58
+
59
+ // Calculate what fraction of text content is hashtags
60
+ const textOnly = inner.replace(/<[^>]*>/g, "").trim();
61
+ const hashtagText = hashtagLinks
62
+ .map((link) => link.replace(/<[^>]*>/g, "").trim())
63
+ .join(" ");
64
+
65
+ // If hashtags make up 80%+ of the text content, collapse
66
+ if (hashtagText.length / Math.max(textOnly.length, 1) >= 0.8) {
67
+ return `<details class="ap-hashtag-overflow"><summary>Show ${hashtagLinks.length} tags</summary><p>${inner}</p></details>`;
68
+ }
69
+
70
+ return match;
71
+ });
72
+ }
@@ -119,12 +119,14 @@ export function mapMastodonStatusToItem(status, instance) {
119
119
  summary: status.spoiler_text || "",
120
120
  sensitive: status.sensitive || false,
121
121
  published: status.created_at || new Date().toISOString(),
122
+ updated: status.edited_at || "",
122
123
  author: {
123
124
  name: sanitizeHtml(account.display_name || account.username || "Unknown", { allowedTags: [], allowedAttributes: {} }),
124
125
  url: account.url || "",
125
126
  photo: account.avatar || account.avatar_static || "",
126
127
  handle,
127
128
  emojis: authorEmojis,
129
+ bot: account.bot || false,
128
130
  },
129
131
  category,
130
132
  mentions,
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { stripQuoteReferenceHtml } from "./og-unfurl.js";
10
10
  import { replaceCustomEmoji } from "./emoji-utils.js";
11
+ import { shortenDisplayUrls, collapseHashtagStuffing } from "./content-utils.js";
11
12
 
12
13
  /**
13
14
  * Post-process timeline items for rendering.
@@ -31,7 +32,10 @@ export async function postProcessItems(items, options = {}) {
31
32
  // 3. Replace custom emoji shortcodes with <img> tags
32
33
  applyCustomEmoji(items);
33
34
 
34
- // 4. Build interaction map (likes/boosts) empty when no collection
35
+ // 4. Shorten long URLs and collapse hashtag stuffing in content
36
+ applyContentEnhancements(items);
37
+
38
+ // 5. Build interaction map (likes/boosts) — empty when no collection
35
39
  const interactionMap = options.interactionsCol
36
40
  ? await buildInteractionMap(items, options.interactionsCol)
37
41
  : {};
@@ -154,6 +158,24 @@ function applyCustomEmoji(items) {
154
158
  }
155
159
  }
156
160
 
161
+ /**
162
+ * Shorten long URLs and collapse hashtag-heavy paragraphs in content.
163
+ * Mutates items in place.
164
+ *
165
+ * @param {Array} items
166
+ */
167
+ function applyContentEnhancements(items) {
168
+ for (const item of items) {
169
+ if (item.content?.html) {
170
+ item.content.html = shortenDisplayUrls(item.content.html);
171
+ item.content.html = collapseHashtagStuffing(item.content.html);
172
+ }
173
+ if (item.quote?.content?.html) {
174
+ item.quote.content.html = shortenDisplayUrls(item.quote.content.html);
175
+ }
176
+ }
177
+ }
178
+
157
179
  /**
158
180
  * Build interaction map (likes/boosts) for template rendering.
159
181
  * Returns { [uid]: { like: true, boost: true } }.
@@ -3,7 +3,7 @@
3
3
  * @module timeline-store
4
4
  */
5
5
 
6
- import { Article, Emoji, Hashtag, Mention } from "@fedify/fedify/vocab";
6
+ import { Article, Application, Emoji, Hashtag, Mention, Service } from "@fedify/fedify/vocab";
7
7
  import sanitizeHtml from "sanitize-html";
8
8
 
9
9
  /**
@@ -101,7 +101,10 @@ export async function extractActorInfo(actor, options = {}) {
101
101
  // Emoji extraction failed — non-critical
102
102
  }
103
103
 
104
- return { name, url, photo, handle, emojis };
104
+ // Bot detection Service and Application actors are automated accounts
105
+ const bot = actor instanceof Service || actor instanceof Application;
106
+
107
+ return { name, url, photo, handle, emojis, bot };
105
108
  }
106
109
 
107
110
  /**
@@ -154,6 +157,9 @@ export async function extractObjectData(object, options = {}) {
154
157
  ? String(object.published)
155
158
  : new Date().toISOString();
156
159
 
160
+ // Edited date — non-null when the post has been updated after publishing
161
+ const updated = object.updated ? String(object.updated) : "";
162
+
157
163
  // Extract author — try multiple strategies in order of reliability
158
164
  const loaderOpts = options.documentLoader ? { documentLoader: options.documentLoader } : {};
159
165
  let authorObj = null;
@@ -304,6 +310,7 @@ export async function extractObjectData(object, options = {}) {
304
310
  summary,
305
311
  sensitive,
306
312
  published,
313
+ updated,
307
314
  author,
308
315
  category,
309
316
  mentions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.5.5",
3
+ "version": "2.6.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",
@@ -53,6 +53,7 @@
53
53
  {% else %}
54
54
  <span>{% if item.author.nameHtml %}{{ item.author.nameHtml | safe }}{% else %}{{ item.author.name or "Unknown" }}{% endif %}</span>
55
55
  {% endif %}
56
+ {% if item.author.bot %}<span class="ap-card__bot-badge" title="Bot account">BOT</span>{% endif %}
56
57
  </div>
57
58
  {% if item.author.handle %}
58
59
  <div class="ap-card__author-handle">{{ item.author.handle }}</div>
@@ -63,6 +64,7 @@
63
64
  <time datetime="{{ item.published }}" class="ap-card__timestamp" x-data x-relative-time>
64
65
  {{ item.published | date("PPp") }}
65
66
  </time>
67
+ {% if item.updated %}<span class="ap-card__edited" title="{{ item.updated | date('PPp') }}">✏️</span>{% endif %}
66
68
  </a>
67
69
  {% endif %}
68
70
  </header>