@rmdes/indiekit-endpoint-activitypub 2.5.4 → 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
  ========================================================================== */
@@ -1595,6 +1642,81 @@
1595
1642
  cursor: pointer;
1596
1643
  }
1597
1644
 
1645
+ /* ==========================================================================
1646
+ Skeleton Loaders
1647
+ ========================================================================== */
1648
+
1649
+ @keyframes ap-skeleton-shimmer {
1650
+ 0% { background-position: 200% 0; }
1651
+ 100% { background-position: -200% 0; }
1652
+ }
1653
+
1654
+ .ap-skeleton {
1655
+ background: linear-gradient(90deg,
1656
+ var(--color-offset) 25%,
1657
+ var(--color-background) 50%,
1658
+ var(--color-offset) 75%);
1659
+ background-size: 200% 100%;
1660
+ animation: ap-skeleton-shimmer 1.5s ease-in-out infinite;
1661
+ border-radius: var(--border-radius-small);
1662
+ }
1663
+
1664
+ .ap-card--skeleton {
1665
+ pointer-events: none;
1666
+ }
1667
+
1668
+ .ap-card--skeleton .ap-card__author {
1669
+ display: flex;
1670
+ align-items: center;
1671
+ gap: var(--space-s);
1672
+ }
1673
+
1674
+ .ap-skeleton--avatar {
1675
+ width: 2.5rem;
1676
+ height: 2.5rem;
1677
+ border-radius: 50%;
1678
+ flex-shrink: 0;
1679
+ }
1680
+
1681
+ .ap-skeleton-lines {
1682
+ flex: 1;
1683
+ display: flex;
1684
+ flex-direction: column;
1685
+ gap: 0.4rem;
1686
+ }
1687
+
1688
+ .ap-skeleton--name {
1689
+ height: 0.85rem;
1690
+ width: 40%;
1691
+ }
1692
+
1693
+ .ap-skeleton--handle {
1694
+ height: 0.7rem;
1695
+ width: 25%;
1696
+ }
1697
+
1698
+ .ap-skeleton-body {
1699
+ display: flex;
1700
+ flex-direction: column;
1701
+ gap: 0.5rem;
1702
+ margin-top: var(--space-s);
1703
+ }
1704
+
1705
+ .ap-skeleton--line {
1706
+ height: 0.75rem;
1707
+ width: 100%;
1708
+ }
1709
+
1710
+ .ap-skeleton--short {
1711
+ width: 60%;
1712
+ }
1713
+
1714
+ .ap-skeleton-group {
1715
+ display: flex;
1716
+ flex-direction: column;
1717
+ gap: var(--space-m);
1718
+ }
1719
+
1598
1720
  /* ==========================================================================
1599
1721
  Responsive
1600
1722
  ========================================================================== */
@@ -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.4",
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",
@@ -256,10 +256,14 @@
256
256
  x-data="apInfiniteScroll()"
257
257
  x-init="init()">
258
258
  <div class="ap-load-more__sentinel" x-ref="sentinel"></div>
259
- <button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
260
- <span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
261
- <span x-show="loading">{{ __("activitypub.reader.pagination.loading") }}</span>
259
+ <button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done && !loading">
260
+ {{ __("activitypub.reader.pagination.loadMore") }}
262
261
  </button>
262
+ <div class="ap-skeleton-group" x-show="loading" x-cloak>
263
+ {% include "partials/ap-skeleton-card.njk" %}
264
+ {% include "partials/ap-skeleton-card.njk" %}
265
+ {% include "partials/ap-skeleton-card.njk" %}
266
+ </div>
263
267
  <p class="ap-load-more__done" x-show="done" x-cloak>{{ __("activitypub.reader.pagination.noMore") }}</p>
264
268
  </div>
265
269
  {% endif %}
@@ -283,10 +287,12 @@
283
287
  <template x-if="tab.type === 'instance'">
284
288
  <div class="ap-explore-instance-panel">
285
289
 
286
- {# Loading spinner — first load, no content yet #}
287
- <div class="ap-explore-tab-loading"
290
+ {# Skeleton loaders — first load, no content yet #}
291
+ <div class="ap-skeleton-group"
288
292
  x-show="tabState[tab._id] && tabState[tab._id].loading && !tabState[tab._id].html">
289
- <span class="ap-explore-tab-loading__text">{{ __("activitypub.reader.pagination.loading") }}</span>
293
+ {% include "partials/ap-skeleton-card.njk" %}
294
+ {% include "partials/ap-skeleton-card.njk" %}
295
+ {% include "partials/ap-skeleton-card.njk" %}
290
296
  </div>
291
297
 
292
298
  {# Error state with retry #}
@@ -304,7 +310,7 @@
304
310
  x-html="tabState[tab._id] ? tabState[tab._id].html : ''">
305
311
  </div>
306
312
 
307
- {# Load more button + loading spinner for subsequent pages #}
313
+ {# Load more button + skeleton loaders for subsequent pages #}
308
314
  <div class="ap-load-more"
309
315
  x-show="tabState[tab._id] && tabState[tab._id].html && !tabState[tab._id].done">
310
316
  <button class="ap-load-more__btn"
@@ -313,10 +319,11 @@
313
319
  :disabled="tabState[tab._id]?.loading">
314
320
  {{ __("activitypub.reader.pagination.loadMore") }}
315
321
  </button>
316
- <span class="ap-explore-tab-loading__text"
322
+ <div class="ap-skeleton-group"
317
323
  x-show="tabState[tab._id]?.loading">
318
- {{ __("activitypub.reader.pagination.loading") }}
319
- </span>
324
+ {% include "partials/ap-skeleton-card.njk" %}
325
+ {% include "partials/ap-skeleton-card.njk" %}
326
+ </div>
320
327
  </div>
321
328
 
322
329
  {# Empty state — loaded successfully but no posts #}
@@ -347,10 +354,12 @@
347
354
  x-text="hashtagSourcesLine(tab)"
348
355
  x-cloak></p>
349
356
 
350
- {# Loading spinner — first load, no content yet #}
351
- <div class="ap-explore-tab-loading"
357
+ {# Skeleton loaders — first load, no content yet #}
358
+ <div class="ap-skeleton-group"
352
359
  x-show="tabState[tab._id] && tabState[tab._id].loading && !tabState[tab._id].html">
353
- <span class="ap-explore-tab-loading__text">{{ __("activitypub.reader.pagination.loading") }}</span>
360
+ {% include "partials/ap-skeleton-card.njk" %}
361
+ {% include "partials/ap-skeleton-card.njk" %}
362
+ {% include "partials/ap-skeleton-card.njk" %}
354
363
  </div>
355
364
 
356
365
  {# Error state with retry #}
@@ -368,7 +377,7 @@
368
377
  x-html="tabState[tab._id] ? tabState[tab._id].html : ''">
369
378
  </div>
370
379
 
371
- {# Load more button + loading spinner for subsequent pages #}
380
+ {# Load more button + skeleton loaders for subsequent pages #}
372
381
  <div class="ap-load-more"
373
382
  x-show="tabState[tab._id] && tabState[tab._id].html && !tabState[tab._id].done">
374
383
  <button class="ap-load-more__btn"
@@ -377,10 +386,11 @@
377
386
  :disabled="tabState[tab._id]?.loading">
378
387
  {{ __("activitypub.reader.pagination.loadMore") }}
379
388
  </button>
380
- <span class="ap-explore-tab-loading__text"
389
+ <div class="ap-skeleton-group"
381
390
  x-show="tabState[tab._id]?.loading">
382
- {{ __("activitypub.reader.pagination.loading") }}
383
- </span>
391
+ {% include "partials/ap-skeleton-card.njk" %}
392
+ {% include "partials/ap-skeleton-card.njk" %}
393
+ </div>
384
394
  </div>
385
395
 
386
396
  {# Empty state — no instance tabs pinned yet #}
@@ -150,10 +150,14 @@
150
150
  x-data="apInfiniteScroll()"
151
151
  x-init="init()">
152
152
  <div class="ap-load-more__sentinel" x-ref="sentinel"></div>
153
- <button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
154
- <span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
155
- <span x-show="loading">{{ __("activitypub.reader.pagination.loading") }}</span>
153
+ <button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done && !loading">
154
+ {{ __("activitypub.reader.pagination.loadMore") }}
156
155
  </button>
156
+ <div class="ap-skeleton-group" x-show="loading" x-cloak>
157
+ {% include "partials/ap-skeleton-card.njk" %}
158
+ {% include "partials/ap-skeleton-card.njk" %}
159
+ {% include "partials/ap-skeleton-card.njk" %}
160
+ </div>
157
161
  <p class="ap-load-more__done" x-show="done" x-cloak>{{ __("activitypub.reader.pagination.noMore") }}</p>
158
162
  </div>
159
163
  {% endif %}
@@ -75,10 +75,14 @@
75
75
  x-data="apInfiniteScroll()"
76
76
  x-init="init()">
77
77
  <div class="ap-load-more__sentinel" x-ref="sentinel"></div>
78
- <button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
79
- <span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
80
- <span x-show="loading">{{ __("activitypub.reader.pagination.loading") }}</span>
78
+ <button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done && !loading">
79
+ {{ __("activitypub.reader.pagination.loadMore") }}
81
80
  </button>
81
+ <div class="ap-skeleton-group" x-show="loading" x-cloak>
82
+ {% include "partials/ap-skeleton-card.njk" %}
83
+ {% include "partials/ap-skeleton-card.njk" %}
84
+ {% include "partials/ap-skeleton-card.njk" %}
85
+ </div>
82
86
  <p class="ap-load-more__done" x-show="done">{{ __("activitypub.reader.pagination.noMore") }}</p>
83
87
  </div>
84
88
  {% endif %}
@@ -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>
@@ -0,0 +1,15 @@
1
+ {# Skeleton loading card — animated placeholder while content loads #}
2
+ <div class="ap-card ap-card--skeleton" aria-hidden="true">
3
+ <header class="ap-card__author">
4
+ <div class="ap-skeleton ap-skeleton--avatar"></div>
5
+ <div class="ap-skeleton-lines">
6
+ <div class="ap-skeleton ap-skeleton--name"></div>
7
+ <div class="ap-skeleton ap-skeleton--handle"></div>
8
+ </div>
9
+ </header>
10
+ <div class="ap-skeleton-body">
11
+ <div class="ap-skeleton ap-skeleton--line"></div>
12
+ <div class="ap-skeleton ap-skeleton--line"></div>
13
+ <div class="ap-skeleton ap-skeleton--line ap-skeleton--short"></div>
14
+ </div>
15
+ </div>