@rmdes/indiekit-endpoint-activitypub 2.5.0 → 2.5.5

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/README.md CHANGED
@@ -14,13 +14,23 @@ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built o
14
14
  - Configurable actor type (Person, Service, Organization, Group)
15
15
 
16
16
  **Reader**
17
- - Timeline view showing posts from followed accounts
17
+ - Timeline view showing posts from followed accounts with tab filtering (notes, articles, replies, boosts, media)
18
+ - Explore view — browse public timelines from any Mastodon-compatible instance
19
+ - Cross-instance hashtag search — search a hashtag across multiple fediverse instances
20
+ - Tag timeline — view and follow/unfollow specific hashtags
21
+ - Post detail view with threaded context
22
+ - Quote post embeds — quoted posts render as inline cards with author, content, and timestamp (FEP-044f, Misskey, Fedibird formats)
23
+ - Link preview cards via Open Graph metadata unfurling
18
24
  - Notifications for likes, boosts, follows, mentions, and replies
19
25
  - Compose form with dual-path posting (quick AP reply or Micropub blog post)
20
26
  - Native interactions (like, boost, reply, follow/unfollow from the reader)
21
27
  - Remote actor profile pages
22
28
  - Content warnings and sensitive content handling
23
29
  - Media display (images, video, audio)
30
+ - Infinite scroll with IntersectionObserver-based auto-loading
31
+ - New post banner — polls for new items and offers one-click loading
32
+ - Read tracking — marks posts as read on scroll, with unread filter toggle
33
+ - Popular accounts autocomplete in the fediverse lookup bar
24
34
  - Configurable timeline retention
25
35
 
26
36
  **Moderation**
@@ -220,7 +230,11 @@ All admin pages are behind IndieAuth authentication:
220
230
  | Page | Path | Description |
221
231
  |---|---|---|
222
232
  | Dashboard | `/activitypub` | Overview with follower/following counts, recent activity |
223
- | Reader | `/activitypub/admin/reader` | Timeline from followed accounts |
233
+ | Reader | `/activitypub/admin/reader` | Timeline from followed accounts (tabbed: notes, articles, replies, boosts, media) |
234
+ | Explore | `/activitypub/admin/reader/explore` | Browse public timelines from Mastodon-compatible instances |
235
+ | Hashtag Explore | `/activitypub/admin/reader/explore/hashtag` | Search a hashtag across multiple fediverse instances |
236
+ | Tag Timeline | `/activitypub/admin/reader/tag?tag=name` | Posts filtered by a specific hashtag, with follow/unfollow |
237
+ | Post Detail | `/activitypub/admin/reader/post?url=...` | Single post view with quote embeds and link previews |
224
238
  | Notifications | `/activitypub/admin/reader/notifications` | Likes, boosts, follows, mentions, replies |
225
239
  | Compose | `/activitypub/admin/reader/compose` | Reply composer (quick AP or Micropub) |
226
240
  | Moderation | `/activitypub/admin/reader/moderation` | Muted/blocked accounts and keywords |
@@ -329,6 +343,7 @@ This is not a bug — Fedify requires explicit opt-in for signed fetches. But it
329
343
  - **Single actor** — One fediverse identity per Indiekit instance
330
344
  - **No Authorized Fetch enforcement** — `.authorize()` disabled on actor dispatcher (see workarounds above)
331
345
  - **No image upload in reader** — Compose form is text-only
346
+ - **No custom emoji rendering** — Custom emoji shortcodes display as text
332
347
  - **In-process queue without Redis** — Activities may be lost on restart
333
348
 
334
349
  ## License
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Relative timestamps — Alpine.js directive that converts absolute
3
+ * datetime attributes to human-friendly relative strings.
4
+ *
5
+ * Usage: <time datetime="2026-03-03T12:00:00Z" x-data x-relative-time>...</time>
6
+ *
7
+ * The server-rendered absolute time stays as fallback for no-JS clients.
8
+ * Alpine enhances it to relative on hydration, updates every 60s for
9
+ * recent posts, and shows the absolute time on hover via title attribute.
10
+ *
11
+ * Format rules (matching Mastodon/Elk conventions):
12
+ * < 1 minute: "just now"
13
+ * < 60 minutes: "Xm" (e.g. "5m")
14
+ * < 24 hours: "Xh" (e.g. "3h")
15
+ * < 7 days: "Xd" (e.g. "2d")
16
+ * same year: "Mar 3"
17
+ * older: "Mar 3, 2025"
18
+ */
19
+
20
+ document.addEventListener("alpine:init", () => {
21
+ // eslint-disable-next-line no-undef
22
+ Alpine.directive("relative-time", (el) => {
23
+ const iso = el.getAttribute("datetime");
24
+ if (!iso) return;
25
+
26
+ const date = new Date(iso);
27
+ if (Number.isNaN(date.getTime())) return;
28
+
29
+ // Store the original formatted text as the title (hover tooltip)
30
+ const original = el.textContent.trim();
31
+ if (original && !el.getAttribute("title")) {
32
+ el.setAttribute("title", original);
33
+ }
34
+
35
+ function update() {
36
+ el.textContent = formatRelative(date);
37
+ }
38
+
39
+ update();
40
+
41
+ // Only set up interval for recent posts (< 24h old)
42
+ const ageMs = Date.now() - date.getTime();
43
+ if (ageMs < 86_400_000) {
44
+ const interval = setInterval(() => {
45
+ update();
46
+ // Stop updating once older than 24h
47
+ if (Date.now() - date.getTime() >= 86_400_000) {
48
+ clearInterval(interval);
49
+ }
50
+ }, 60_000);
51
+ }
52
+ });
53
+ });
54
+
55
+ /**
56
+ * Format a Date as a relative time string.
57
+ * @param {Date} date
58
+ * @returns {string}
59
+ */
60
+ function formatRelative(date) {
61
+ const now = Date.now();
62
+ const diffMs = now - date.getTime();
63
+ const diffSec = Math.floor(diffMs / 1000);
64
+
65
+ if (diffSec < 60) return "just now";
66
+
67
+ const diffMin = Math.floor(diffSec / 60);
68
+ if (diffMin < 60) return `${diffMin}m`;
69
+
70
+ const diffHour = Math.floor(diffMin / 60);
71
+ if (diffHour < 24) return `${diffHour}h`;
72
+
73
+ const diffDay = Math.floor(diffHour / 24);
74
+ if (diffDay < 7) return `${diffDay}d`;
75
+
76
+ // Older than 7 days — use formatted date
77
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
78
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
79
+ const month = months[date.getMonth()];
80
+ const day = date.getDate();
81
+
82
+ if (date.getFullYear() === new Date().getFullYear()) {
83
+ return `${month} ${day}`;
84
+ }
85
+
86
+ return `${month} ${day}, ${date.getFullYear()}`;
87
+ }
package/assets/reader.css CHANGED
@@ -763,6 +763,14 @@
763
763
  opacity: 0.6;
764
764
  }
765
765
 
766
+ /* Interaction counts */
767
+ .ap-card__count {
768
+ font-size: var(--font-size-xs);
769
+ color: var(--color-on-offset);
770
+ margin-left: 0.25rem;
771
+ font-variant-numeric: tabular-nums;
772
+ }
773
+
766
774
  /* Error message */
767
775
  .ap-card__action-error {
768
776
  color: var(--color-error);
@@ -1587,6 +1595,81 @@
1587
1595
  cursor: pointer;
1588
1596
  }
1589
1597
 
1598
+ /* ==========================================================================
1599
+ Skeleton Loaders
1600
+ ========================================================================== */
1601
+
1602
+ @keyframes ap-skeleton-shimmer {
1603
+ 0% { background-position: 200% 0; }
1604
+ 100% { background-position: -200% 0; }
1605
+ }
1606
+
1607
+ .ap-skeleton {
1608
+ background: linear-gradient(90deg,
1609
+ var(--color-offset) 25%,
1610
+ var(--color-background) 50%,
1611
+ var(--color-offset) 75%);
1612
+ background-size: 200% 100%;
1613
+ animation: ap-skeleton-shimmer 1.5s ease-in-out infinite;
1614
+ border-radius: var(--border-radius-small);
1615
+ }
1616
+
1617
+ .ap-card--skeleton {
1618
+ pointer-events: none;
1619
+ }
1620
+
1621
+ .ap-card--skeleton .ap-card__author {
1622
+ display: flex;
1623
+ align-items: center;
1624
+ gap: var(--space-s);
1625
+ }
1626
+
1627
+ .ap-skeleton--avatar {
1628
+ width: 2.5rem;
1629
+ height: 2.5rem;
1630
+ border-radius: 50%;
1631
+ flex-shrink: 0;
1632
+ }
1633
+
1634
+ .ap-skeleton-lines {
1635
+ flex: 1;
1636
+ display: flex;
1637
+ flex-direction: column;
1638
+ gap: 0.4rem;
1639
+ }
1640
+
1641
+ .ap-skeleton--name {
1642
+ height: 0.85rem;
1643
+ width: 40%;
1644
+ }
1645
+
1646
+ .ap-skeleton--handle {
1647
+ height: 0.7rem;
1648
+ width: 25%;
1649
+ }
1650
+
1651
+ .ap-skeleton-body {
1652
+ display: flex;
1653
+ flex-direction: column;
1654
+ gap: 0.5rem;
1655
+ margin-top: var(--space-s);
1656
+ }
1657
+
1658
+ .ap-skeleton--line {
1659
+ height: 0.75rem;
1660
+ width: 100%;
1661
+ }
1662
+
1663
+ .ap-skeleton--short {
1664
+ width: 60%;
1665
+ }
1666
+
1667
+ .ap-skeleton-group {
1668
+ display: flex;
1669
+ flex-direction: column;
1670
+ gap: var(--space-m);
1671
+ }
1672
+
1590
1673
  /* ==========================================================================
1591
1674
  Responsive
1592
1675
  ========================================================================== */
@@ -2528,3 +2611,54 @@
2528
2611
  padding: var(--space-s) 0 var(--space-xs);
2529
2612
  }
2530
2613
 
2614
+ /* Custom emoji */
2615
+ .ap-custom-emoji {
2616
+ height: 1.2em;
2617
+ width: auto;
2618
+ vertical-align: middle;
2619
+ display: inline;
2620
+ margin: 0 0.05em;
2621
+ }
2622
+
2623
+ /* Gallery items — positioned for ALT badge overlay */
2624
+ .ap-card__gallery-item {
2625
+ position: relative;
2626
+ }
2627
+
2628
+ /* ALT text badges */
2629
+ .ap-media__alt-badge {
2630
+ position: absolute;
2631
+ bottom: 0.5rem;
2632
+ left: 0.5rem;
2633
+ background: rgba(0, 0, 0, 0.7);
2634
+ color: white;
2635
+ font-size: 0.65rem;
2636
+ font-weight: 700;
2637
+ padding: 0.15rem 0.35rem;
2638
+ border-radius: var(--border-radius-small);
2639
+ border: none;
2640
+ cursor: pointer;
2641
+ text-transform: uppercase;
2642
+ letter-spacing: 0.03em;
2643
+ z-index: 1;
2644
+ }
2645
+
2646
+ .ap-media__alt-badge:hover {
2647
+ background: rgba(0, 0, 0, 0.9);
2648
+ }
2649
+
2650
+ .ap-media__alt-text {
2651
+ position: absolute;
2652
+ bottom: 2.2rem;
2653
+ left: 0.5rem;
2654
+ right: 0.5rem;
2655
+ background: rgba(0, 0, 0, 0.85);
2656
+ color: white;
2657
+ font-size: var(--font-size-s);
2658
+ padding: 0.5rem;
2659
+ border-radius: var(--border-radius-small);
2660
+ max-height: 8rem;
2661
+ overflow-y: auto;
2662
+ z-index: 2;
2663
+ }
2664
+
@@ -84,7 +84,14 @@ export function mapMastodonStatusToItem(status, instance) {
84
84
  const url = att.url || att.remote_url || "";
85
85
  if (!url) continue;
86
86
  if (att.type === "image" || att.type === "gifv") {
87
- photo.push(url);
87
+ photo.push({
88
+ url,
89
+ alt: att.description || "",
90
+ width: att.meta?.original?.width || null,
91
+ height: att.meta?.original?.height || null,
92
+ blurhash: att.blurhash || "",
93
+ focus: att.meta?.focus || null,
94
+ });
88
95
  } else if (att.type === "video") {
89
96
  video.push(url);
90
97
  } else if (att.type === "audio") {
@@ -92,6 +99,14 @@ export function mapMastodonStatusToItem(status, instance) {
92
99
  }
93
100
  }
94
101
 
102
+ // Extract custom emoji — Mastodon API provides emojis on both status and account
103
+ const emojis = (status.emojis || [])
104
+ .filter((e) => e.shortcode && e.url)
105
+ .map((e) => ({ shortcode: e.shortcode, url: e.url }));
106
+ const authorEmojis = (account.emojis || [])
107
+ .filter((e) => e.shortcode && e.url)
108
+ .map((e) => ({ shortcode: e.shortcode, url: e.url }));
109
+
95
110
  const item = {
96
111
  uid: status.url || status.uri || "",
97
112
  url: status.url || status.uri || "",
@@ -109,13 +124,20 @@ export function mapMastodonStatusToItem(status, instance) {
109
124
  url: account.url || "",
110
125
  photo: account.avatar || account.avatar_static || "",
111
126
  handle,
127
+ emojis: authorEmojis,
112
128
  },
113
129
  category,
114
130
  mentions,
131
+ emojis,
115
132
  photo,
116
133
  video,
117
134
  audio,
118
135
  inReplyTo: status.in_reply_to_id ? `https://${instance}/web/statuses/${status.in_reply_to_id}` : "",
136
+ counts: {
137
+ replies: status.replies_count ?? null,
138
+ boosts: status.reblogs_count ?? null,
139
+ likes: status.favourites_count ?? null,
140
+ },
119
141
  createdAt: new Date().toISOString(),
120
142
  _explore: true,
121
143
  };
@@ -134,7 +156,12 @@ export function mapMastodonStatusToItem(status, instance) {
134
156
  for (const att of q.media_attachments || []) {
135
157
  const attUrl = att.url || att.remote_url || "";
136
158
  if (attUrl && (att.type === "image" || att.type === "gifv")) {
137
- qPhoto.push(attUrl);
159
+ qPhoto.push({
160
+ url: attUrl,
161
+ alt: att.description || "",
162
+ width: att.meta?.original?.width || null,
163
+ height: att.meta?.original?.height || null,
164
+ });
138
165
  }
139
166
  }
140
167
 
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Custom emoji replacement for fediverse content.
3
+ *
4
+ * Replaces :shortcode: patterns with <img> tags for custom emoji.
5
+ * Must be called AFTER sanitizeContent() — the inserted <img> tags
6
+ * would be stripped if run through the sanitizer.
7
+ */
8
+
9
+ /**
10
+ * Escape special regex characters in a string.
11
+ * @param {string} str
12
+ * @returns {string}
13
+ */
14
+ function escapeRegex(str) {
15
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16
+ }
17
+
18
+ /**
19
+ * Replace :shortcode: patterns in HTML with custom emoji <img> tags.
20
+ *
21
+ * @param {string} html - HTML string (already sanitized)
22
+ * @param {Array<{shortcode: string, url: string}>} emojis - Custom emoji list
23
+ * @returns {string} HTML with emoji shortcodes replaced by img tags
24
+ */
25
+ export function replaceCustomEmoji(html, emojis) {
26
+ if (!html || !emojis?.length) return html;
27
+
28
+ for (const emoji of emojis) {
29
+ if (!emoji.shortcode || !emoji.url) continue;
30
+ const pattern = new RegExp(`:${escapeRegex(emoji.shortcode)}:`, "g");
31
+ html = html.replace(
32
+ pattern,
33
+ `<img src="${emoji.url}" alt=":${emoji.shortcode}:" title=":${emoji.shortcode}:" class="ap-custom-emoji" loading="lazy">`,
34
+ );
35
+ }
36
+
37
+ return html;
38
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { stripQuoteReferenceHtml } from "./og-unfurl.js";
10
+ import { replaceCustomEmoji } from "./emoji-utils.js";
10
11
 
11
12
  /**
12
13
  * Post-process timeline items for rendering.
@@ -27,7 +28,10 @@ export async function postProcessItems(items, options = {}) {
27
28
  // 2. Strip "RE:" paragraphs from items with quote embeds
28
29
  stripQuoteReferences(items);
29
30
 
30
- // 3. Build interaction map (likes/boosts) empty when no collection
31
+ // 3. Replace custom emoji shortcodes with <img> tags
32
+ applyCustomEmoji(items);
33
+
34
+ // 4. Build interaction map (likes/boosts) — empty when no collection
31
35
  const interactionMap = options.interactionsCol
32
36
  ? await buildInteractionMap(items, options.interactionsCol)
33
37
  : {};
@@ -111,6 +115,45 @@ export function stripQuoteReferences(items) {
111
115
  }
112
116
  }
113
117
 
118
+ /**
119
+ * Replace custom emoji :shortcode: patterns with <img> tags.
120
+ * Handles both content HTML and display names.
121
+ * Mutates items in place.
122
+ *
123
+ * @param {Array} items
124
+ */
125
+ function applyCustomEmoji(items) {
126
+ for (const item of items) {
127
+ // Replace emoji in post content
128
+ if (item.emojis?.length && item.content?.html) {
129
+ item.content.html = replaceCustomEmoji(item.content.html, item.emojis);
130
+ }
131
+
132
+ // Replace emoji in author display name → stored as author.nameHtml
133
+ const authorEmojis = item.author?.emojis;
134
+ if (authorEmojis?.length && item.author?.name) {
135
+ item.author.nameHtml = replaceCustomEmoji(item.author.name, authorEmojis);
136
+ }
137
+
138
+ // Replace emoji in boostedBy display name
139
+ const boostEmojis = item.boostedBy?.emojis;
140
+ if (boostEmojis?.length && item.boostedBy?.name) {
141
+ item.boostedBy.nameHtml = replaceCustomEmoji(item.boostedBy.name, boostEmojis);
142
+ }
143
+
144
+ // Replace emoji in quote embed content and author name
145
+ if (item.quote) {
146
+ if (item.quote.emojis?.length && item.quote.content?.html) {
147
+ item.quote.content.html = replaceCustomEmoji(item.quote.content.html, item.quote.emojis);
148
+ }
149
+ const qAuthorEmojis = item.quote.author?.emojis;
150
+ if (qAuthorEmojis?.length && item.quote.author?.name) {
151
+ item.quote.author.nameHtml = replaceCustomEmoji(item.quote.author.name, qAuthorEmojis);
152
+ }
153
+ }
154
+ }
155
+ }
156
+
114
157
  /**
115
158
  * Build interaction map (likes/boosts) for template rendering.
116
159
  * Returns { [uid]: { like: true, boost: true } }.
@@ -3,7 +3,7 @@
3
3
  * @module timeline-store
4
4
  */
5
5
 
6
- import { Article, Hashtag, Mention } from "@fedify/fedify/vocab";
6
+ import { Article, Emoji, Hashtag, Mention } from "@fedify/fedify/vocab";
7
7
  import sanitizeHtml from "sanitize-html";
8
8
 
9
9
  /**
@@ -82,7 +82,26 @@ export async function extractActorInfo(actor, options = {}) {
82
82
  // Invalid URL, keep handle empty
83
83
  }
84
84
 
85
- return { name, url, photo, handle };
85
+ // Extract custom emoji from actor tags
86
+ const emojis = [];
87
+ try {
88
+ if (typeof actor.getTags === "function") {
89
+ const tags = await actor.getTags(loaderOpts);
90
+ for await (const tag of tags) {
91
+ if (tag instanceof Emoji) {
92
+ const shortcode = (tag.name?.toString() || "").replace(/^:|:$/g, "");
93
+ const iconUrl = tag.iconId?.href || "";
94
+ if (shortcode && iconUrl) {
95
+ emojis.push({ shortcode, url: iconUrl });
96
+ }
97
+ }
98
+ }
99
+ }
100
+ } catch {
101
+ // Emoji extraction failed — non-critical
102
+ }
103
+
104
+ return { name, url, photo, handle, emojis };
86
105
  }
87
106
 
88
107
  /**
@@ -190,8 +209,10 @@ export async function extractObjectData(object, options = {}) {
190
209
  // Extract tags — Fedify uses async getTags() which returns typed vocab objects.
191
210
  // Hashtag → category[] (plain strings, # prefix stripped)
192
211
  // Mention → mentions[] ({ name, url } objects for profile linking)
212
+ // Emoji → emojis[] ({ shortcode, url } for custom emoji rendering)
193
213
  const category = [];
194
214
  const mentions = [];
215
+ const emojis = [];
195
216
  try {
196
217
  if (typeof object.getTags === "function") {
197
218
  const tags = await object.getTags(loaderOpts);
@@ -206,6 +227,13 @@ export async function extractObjectData(object, options = {}) {
206
227
  // tag.href is a URL object — use .href to get the string
207
228
  const mentionUrl = tag.href?.href || "";
208
229
  if (mentionName) mentions.push({ name: mentionName, url: mentionUrl });
230
+ } else if (tag instanceof Emoji) {
231
+ // Custom emoji: name is ":shortcode:", icon is an Image with url
232
+ const shortcode = (tag.name?.toString() || "").replace(/^:|:$/g, "");
233
+ const iconUrl = tag.iconId?.href || "";
234
+ if (shortcode && iconUrl) {
235
+ emojis.push({ shortcode, url: iconUrl });
236
+ }
209
237
  }
210
238
  }
211
239
  }
@@ -228,7 +256,12 @@ export async function extractObjectData(object, options = {}) {
228
256
  const mediaType = att.mediaType?.toLowerCase() || "";
229
257
 
230
258
  if (mediaType.startsWith("image/")) {
231
- photo.push(mediaUrl);
259
+ photo.push({
260
+ url: mediaUrl,
261
+ alt: att.name?.toString() || "",
262
+ width: att.width || null,
263
+ height: att.height || null,
264
+ });
232
265
  } else if (mediaType.startsWith("video/")) {
233
266
  video.push(mediaUrl);
234
267
  } else if (mediaType.startsWith("audio/")) {
@@ -246,6 +279,21 @@ export async function extractObjectData(object, options = {}) {
246
279
  // Quote URL — Fedify reads quoteUrl / _misskey_quote / quoteUri
247
280
  const quoteUrl = object.quoteUrl?.href || "";
248
281
 
282
+ // Interaction counts — extract from AP Collection objects
283
+ const counts = { replies: null, boosts: null, likes: null };
284
+ try {
285
+ const replies = await object.getReplies?.(loaderOpts);
286
+ if (replies?.totalItems != null) counts.replies = replies.totalItems;
287
+ } catch { /* ignore — collection may not exist */ }
288
+ try {
289
+ const likes = await object.getLikes?.(loaderOpts);
290
+ if (likes?.totalItems != null) counts.likes = likes.totalItems;
291
+ } catch { /* ignore */ }
292
+ try {
293
+ const shares = await object.getShares?.(loaderOpts);
294
+ if (shares?.totalItems != null) counts.boosts = shares.totalItems;
295
+ } catch { /* ignore */ }
296
+
249
297
  // Build base timeline item
250
298
  const item = {
251
299
  uid,
@@ -259,11 +307,13 @@ export async function extractObjectData(object, options = {}) {
259
307
  author,
260
308
  category,
261
309
  mentions,
310
+ emojis,
262
311
  photo,
263
312
  video,
264
313
  audio,
265
314
  inReplyTo,
266
315
  quoteUrl,
316
+ counts,
267
317
  createdAt: new Date().toISOString()
268
318
  };
269
319
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.5.0",
3
+ "version": "2.5.5",
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 %}
@@ -7,6 +7,8 @@
7
7
  <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-autocomplete.js"></script>
8
8
  {# Tab components — apExploreTabs #}
9
9
  <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-tabs.js"></script>
10
+ {# Relative timestamps — converts absolute dates to "5m", "3h", "2d" etc. #}
11
+ <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-relative-time.js"></script>
10
12
 
11
13
  {# Alpine.js for client-side reactivity (CW toggles, interaction buttons, infinite scroll) #}
12
14
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
@@ -26,7 +26,7 @@
26
26
  {# Boost header if this is a boosted post #}
27
27
  {% if item.type == "boost" and item.boostedBy %}
28
28
  <div class="ap-card__boost">
29
- 🔁 {% if item.boostedBy.url %}<a href="{{ mountPath }}/admin/reader/profile?url={{ item.boostedBy.url | urlencode }}">{{ item.boostedBy.name or "Someone" }}</a>{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %} {{ __("activitypub.reader.boosted") }}
29
+ 🔁 {% if item.boostedBy.url %}<a href="{{ mountPath }}/admin/reader/profile?url={{ item.boostedBy.url | urlencode }}">{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}</a>{% else %}{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}{% endif %} {{ __("activitypub.reader.boosted") }}
30
30
  </div>
31
31
  {% endif %}
32
32
 
@@ -49,9 +49,9 @@
49
49
  <div class="ap-card__author-info">
50
50
  <div class="ap-card__author-name">
51
51
  {% if item.author.url %}
52
- <a href="{{ mountPath }}/admin/reader/profile?url={{ item.author.url | urlencode }}">{{ item.author.name or "Unknown" }}</a>
52
+ <a href="{{ mountPath }}/admin/reader/profile?url={{ item.author.url | urlencode }}">{% if item.author.nameHtml %}{{ item.author.nameHtml | safe }}{% else %}{{ item.author.name or "Unknown" }}{% endif %}</a>
53
53
  {% else %}
54
- <span>{{ item.author.name or "Unknown" }}</span>
54
+ <span>{% if item.author.nameHtml %}{{ item.author.nameHtml | safe }}{% else %}{{ item.author.name or "Unknown" }}{% endif %}</span>
55
55
  {% endif %}
56
56
  </div>
57
57
  {% if item.author.handle %}
@@ -60,7 +60,7 @@
60
60
  </div>
61
61
  {% if item.published %}
62
62
  <a href="{{ mountPath }}/admin/reader/post?url={{ (item.uid or item.url) | urlencode }}" class="ap-card__timestamp-link" title="{{ __('activitypub.reader.post.title') }}">
63
- <time datetime="{{ item.published }}" class="ap-card__timestamp">
63
+ <time datetime="{{ item.published }}" class="ap-card__timestamp" x-data x-relative-time>
64
64
  {{ item.published | date("PPp") }}
65
65
  </time>
66
66
  </a>
@@ -149,6 +149,9 @@
149
149
  {% set itemUid = item.uid or item.url or item.originalUrl %}
150
150
  {% set isLiked = interactionMap[itemUid].like if interactionMap[itemUid] else false %}
151
151
  {% set isBoosted = interactionMap[itemUid].boost if interactionMap[itemUid] else false %}
152
+ {% set replyCount = item.counts.replies if item.counts and item.counts.replies != null else null %}
153
+ {% set boostCount = item.counts.boosts if item.counts and item.counts.boosts != null else null %}
154
+ {% set likeCount = item.counts.likes if item.counts and item.counts.likes != null else null %}
152
155
  <footer class="ap-card__actions"
153
156
  data-item-uid="{{ itemUid }}"
154
157
  data-item-url="{{ itemUrl }}"
@@ -160,6 +163,8 @@
160
163
  saved: false,
161
164
  loading: false,
162
165
  error: '',
166
+ boostCount: {{ boostCount if boostCount != null else 'null' }},
167
+ likeCount: {{ likeCount if likeCount != null else 'null' }},
163
168
  async saveLater() {
164
169
  if (this.saved) return;
165
170
  const el = this.$root;
@@ -190,11 +195,11 @@
190
195
  const itemUid = el.dataset.itemUid;
191
196
  const csrfToken = el.dataset.csrfToken;
192
197
  const basePath = el.dataset.mountPath;
193
- const prev = { liked: this.liked, boosted: this.boosted };
194
- if (action === 'like') this.liked = true;
195
- else if (action === 'unlike') this.liked = false;
196
- else if (action === 'boost') this.boosted = true;
197
- else if (action === 'unboost') this.boosted = false;
198
+ const prev = { liked: this.liked, boosted: this.boosted, boostCount: this.boostCount, likeCount: this.likeCount };
199
+ if (action === 'like') { this.liked = true; if (this.likeCount !== null) this.likeCount++; }
200
+ else if (action === 'unlike') { this.liked = false; if (this.likeCount !== null && this.likeCount > 0) this.likeCount--; }
201
+ else if (action === 'boost') { this.boosted = true; if (this.boostCount !== null) this.boostCount++; }
202
+ else if (action === 'unboost') { this.boosted = false; if (this.boostCount !== null && this.boostCount > 0) this.boostCount--; }
198
203
  try {
199
204
  const res = await fetch(basePath + '/admin/reader/' + action, {
200
205
  method: 'POST',
@@ -208,11 +213,15 @@
208
213
  if (!data.success) {
209
214
  this.liked = prev.liked;
210
215
  this.boosted = prev.boosted;
216
+ this.boostCount = prev.boostCount;
217
+ this.likeCount = prev.likeCount;
211
218
  this.error = data.error || 'Failed';
212
219
  }
213
220
  } catch (e) {
214
221
  this.liked = prev.liked;
215
222
  this.boosted = prev.boosted;
223
+ this.boostCount = prev.boostCount;
224
+ this.likeCount = prev.likeCount;
216
225
  this.error = e.message;
217
226
  }
218
227
  this.loading = false;
@@ -222,14 +231,14 @@
222
231
  <a href="{{ mountPath }}/admin/reader/compose?replyTo={{ itemUid | urlencode }}"
223
232
  class="ap-card__action ap-card__action--reply"
224
233
  title="{{ __('activitypub.reader.actions.reply') }}">
225
- ↩ {{ __("activitypub.reader.actions.reply") }}
234
+ ↩ {{ __("activitypub.reader.actions.reply") }}{% if replyCount != null %}<span class="ap-card__count">{{ replyCount }}</span>{% endif %}
226
235
  </a>
227
236
  <button class="ap-card__action ap-card__action--boost"
228
237
  :class="{ 'ap-card__action--active': boosted }"
229
238
  :title="boosted ? '{{ __('activitypub.reader.actions.unboost') }}' : '{{ __('activitypub.reader.actions.boost') }}'"
230
239
  :disabled="loading"
231
240
  @click="interact(boosted ? 'unboost' : 'boost')">
232
- 🔁 <span x-text="boosted ? '{{ __('activitypub.reader.actions.boosted') }}' : '{{ __('activitypub.reader.actions.boost') }}'"></span>
241
+ 🔁 <span x-text="boosted ? '{{ __('activitypub.reader.actions.boosted') }}' : '{{ __('activitypub.reader.actions.boost') }}'"></span><template x-if="boostCount !== null"><span class="ap-card__count" x-text="boostCount"></span></template>
233
242
  </button>
234
243
  <button class="ap-card__action ap-card__action--like"
235
244
  :class="{ 'ap-card__action--active': liked }"
@@ -237,7 +246,7 @@
237
246
  :disabled="loading"
238
247
  @click="interact(liked ? 'unlike' : 'like')">
239
248
  <span x-text="liked ? '❤️' : '♥'"></span>
240
- <span x-text="liked ? '{{ __('activitypub.reader.actions.liked') }}' : '{{ __('activitypub.reader.actions.like') }}'"></span>
249
+ <span x-text="liked ? '{{ __('activitypub.reader.actions.liked') }}' : '{{ __('activitypub.reader.actions.like') }}'"></span><template x-if="likeCount !== null"><span class="ap-card__count" x-text="likeCount"></span></template>
241
250
  </button>
242
251
  <a href="{{ itemUrl }}" class="ap-card__action ap-card__action--link" target="_blank" rel="noopener">
243
252
  🔗 {{ __("activitypub.reader.actions.viewOriginal") }}
@@ -6,14 +6,23 @@
6
6
  {% set extraCount = item.photo.length - 4 %}
7
7
  {% set totalPhotos = item.photo.length %}
8
8
  <div x-data="{ lightbox: false, idx: 0 }" class="ap-card__gallery ap-card__gallery--{{ displayCount }}">
9
- {% for photoUrl in item.photo %}
9
+ {% for photo in item.photo %}
10
+ {# Support both old string format and new object format #}
11
+ {% set photoSrc = photo.url if photo.url else photo %}
12
+ {% set photoAlt = photo.alt if photo.alt else "" %}
10
13
  {% if loop.index0 < 4 %}
11
- <button type="button" @click="idx = {{ loop.index0 }}; lightbox = true" class="ap-card__gallery-link{% if loop.index0 == 3 and extraCount > 0 %} ap-card__gallery-link--more{% endif %}">
12
- <img src="{{ photoUrl }}" alt="" loading="lazy">
13
- {% if loop.index0 == 3 and extraCount > 0 %}
14
- <span class="ap-card__gallery-more">+{{ extraCount }}</span>
14
+ <div class="ap-card__gallery-item" x-data="{ showAlt: false }">
15
+ <button type="button" @click="idx = {{ loop.index0 }}; lightbox = true" class="ap-card__gallery-link{% if loop.index0 == 3 and extraCount > 0 %} ap-card__gallery-link--more{% endif %}">
16
+ <img src="{{ photoSrc }}" alt="{{ photoAlt }}" loading="lazy">
17
+ {% if loop.index0 == 3 and extraCount > 0 %}
18
+ <span class="ap-card__gallery-more">+{{ extraCount }}</span>
19
+ {% endif %}
20
+ </button>
21
+ {% if photoAlt %}
22
+ <button type="button" class="ap-media__alt-badge" @click.stop="showAlt = !showAlt" :aria-expanded="showAlt">ALT</button>
23
+ <div class="ap-media__alt-text" x-show="showAlt" x-cloak @click.stop>{{ photoAlt }}</div>
15
24
  {% endif %}
16
- </button>
25
+ </div>
17
26
  {% endif %}
18
27
  {% endfor %}
19
28
 
@@ -24,7 +33,9 @@
24
33
  {% if totalPhotos > 1 %}
25
34
  <button type="button" @click="idx = (idx - 1 + {{ totalPhotos }}) % {{ totalPhotos }}" class="ap-lightbox__prev" aria-label="Previous image">&lsaquo;</button>
26
35
  {% endif %}
27
- <img :src="[{% for p in item.photo %}'{{ p }}'{% if not loop.last %},{% endif %}{% endfor %}][idx]" class="ap-lightbox__img" alt="">
36
+ <img :src="[{% for photo in item.photo %}'{{ photo.url if photo.url else photo }}'{% if not loop.last %},{% endif %}{% endfor %}][idx]"
37
+ :alt="[{% for photo in item.photo %}'{{ (photo.alt if photo.alt else '') | replace(\"'\", \"\\'\") }}'{% if not loop.last %},{% endif %}{% endfor %}][idx]"
38
+ class="ap-lightbox__img">
28
39
  {% if totalPhotos > 1 %}
29
40
  <button type="button" @click="idx = (idx + 1) % {{ totalPhotos }}" class="ap-lightbox__next" aria-label="Next image">&rsaquo;</button>
30
41
  <div class="ap-lightbox__counter" x-text="(idx + 1) + ' / ' + {{ totalPhotos }}"></div>
@@ -68,7 +68,7 @@
68
68
 
69
69
  {# Timestamp #}
70
70
  {% if item.published %}
71
- <time datetime="{{ item.published }}" class="ap-notification__time">
71
+ <time datetime="{{ item.published }}" class="ap-notification__time" x-data x-relative-time>
72
72
  {{ item.published | date("PPp") }}
73
73
  </time>
74
74
  {% endif %}
@@ -9,13 +9,13 @@
9
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
10
  {% endif %}
11
11
  <div class="ap-quote-embed__author-info">
12
- <div class="ap-quote-embed__name">{{ item.quote.author.name or "Unknown" }}</div>
12
+ <div class="ap-quote-embed__name">{% if item.quote.author.nameHtml %}{{ item.quote.author.nameHtml | safe }}{% else %}{{ item.quote.author.name or "Unknown" }}{% endif %}</div>
13
13
  {% if item.quote.author.handle %}
14
14
  <div class="ap-quote-embed__handle">{{ item.quote.author.handle }}</div>
15
15
  {% endif %}
16
16
  </div>
17
17
  {% if item.quote.published %}
18
- <time datetime="{{ item.quote.published }}" class="ap-quote-embed__time">{{ item.quote.published | date("PPp") }}</time>
18
+ <time datetime="{{ item.quote.published }}" class="ap-quote-embed__time" x-data x-relative-time>{{ item.quote.published | date("PPp") }}</time>
19
19
  {% endif %}
20
20
  </header>
21
21
  {% if item.quote.name %}
@@ -25,8 +25,9 @@
25
25
  <div class="ap-quote-embed__content">{{ item.quote.content.html | safe }}</div>
26
26
  {% endif %}
27
27
  {% if item.quote.photo and item.quote.photo.length > 0 %}
28
+ {% set qPhoto = item.quote.photo[0] %}
28
29
  <div class="ap-quote-embed__media">
29
- <img src="{{ item.quote.photo[0] }}" alt="" loading="lazy" class="ap-quote-embed__photo">
30
+ <img src="{{ qPhoto.url if qPhoto.url else qPhoto }}" alt="{{ qPhoto.alt if qPhoto.alt else '' }}" loading="lazy" class="ap-quote-embed__photo">
30
31
  </div>
31
32
  {% endif %}
32
33
  </a>
@@ -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>