@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 +122 -0
- package/lib/content-utils.js +72 -0
- package/lib/controllers/explore-utils.js +2 -0
- package/lib/item-processing.js +23 -1
- package/lib/timeline-store.js +9 -2
- package/package.json +1 -1
- package/views/activitypub-explore.njk +27 -17
- package/views/activitypub-reader.njk +7 -3
- package/views/activitypub-tag-timeline.njk +7 -3
- package/views/partials/ap-item-card.njk +2 -0
- package/views/partials/ap-skeleton-card.njk +15 -0
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,
|
package/lib/item-processing.js
CHANGED
|
@@ -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.
|
|
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 } }.
|
package/lib/timeline-store.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
{#
|
|
287
|
-
<div class="ap-
|
|
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
|
-
|
|
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 +
|
|
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
|
-
<
|
|
322
|
+
<div class="ap-skeleton-group"
|
|
317
323
|
x-show="tabState[tab._id]?.loading">
|
|
318
|
-
{
|
|
319
|
-
|
|
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
|
-
{#
|
|
351
|
-
<div class="ap-
|
|
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
|
-
|
|
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 +
|
|
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
|
-
<
|
|
389
|
+
<div class="ap-skeleton-group"
|
|
381
390
|
x-show="tabState[tab._id]?.loading">
|
|
382
|
-
{
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|