@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 +17 -2
- package/assets/reader-relative-time.js +87 -0
- package/assets/reader.css +134 -0
- package/lib/controllers/explore-utils.js +29 -2
- package/lib/emoji-utils.js +38 -0
- package/lib/item-processing.js +44 -1
- package/lib/timeline-store.js +53 -3
- 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/layouts/ap-reader.njk +2 -0
- package/views/partials/ap-item-card.njk +21 -12
- package/views/partials/ap-item-media.njk +18 -7
- package/views/partials/ap-notification-card.njk +1 -1
- package/views/partials/ap-quote-embed.njk +4 -3
- package/views/partials/ap-skeleton-card.njk +15 -0
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(
|
|
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(
|
|
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
|
+
}
|
package/lib/item-processing.js
CHANGED
|
@@ -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.
|
|
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 } }.
|
package/lib/timeline-store.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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 %}
|
|
@@ -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
|
|
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
|
-
<
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
</
|
|
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">‹</button>
|
|
26
35
|
{% endif %}
|
|
27
|
-
<img :src="[{% for
|
|
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">›</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="{{
|
|
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>
|