@rmdes/indiekit-endpoint-activitypub 2.4.5 → 2.5.4
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-infinite-scroll.js +48 -118
- package/assets/reader-relative-time.js +87 -0
- package/assets/reader.css +59 -0
- package/lib/controllers/api-timeline.js +13 -119
- package/lib/controllers/explore-utils.js +29 -2
- package/lib/controllers/explore.js +61 -117
- package/lib/controllers/hashtag-explore.js +7 -28
- package/lib/controllers/reader.js +11 -132
- package/lib/controllers/tag-timeline.js +8 -80
- package/lib/emoji-utils.js +38 -0
- package/lib/item-processing.js +269 -0
- package/lib/timeline-store.js +53 -3
- package/package.json +1 -1
- package/views/activitypub-explore.njk +7 -5
- package/views/activitypub-reader.njk +8 -5
- package/views/activitypub-tag-timeline.njk +9 -5
- 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/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
|
|
@@ -1,121 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Infinite scroll — AlpineJS component for AJAX load-more
|
|
3
|
-
*
|
|
2
|
+
* Infinite scroll — unified AlpineJS component for AJAX load-more.
|
|
3
|
+
* Works for both reader timeline and explore view via data attributes.
|
|
4
|
+
*
|
|
5
|
+
* Required data attributes on the component element:
|
|
6
|
+
* data-cursor — initial pagination cursor value
|
|
7
|
+
* data-api-url — API endpoint URL (e.g., /activitypub/admin/reader/api/timeline)
|
|
8
|
+
* data-cursor-param — query param name for the cursor (e.g., "before" or "max_id")
|
|
9
|
+
* data-cursor-field — response JSON field for the next cursor (e.g., "before" or "maxId")
|
|
10
|
+
* data-timeline-id — DOM ID of the timeline container to append HTML into
|
|
11
|
+
*
|
|
12
|
+
* Optional:
|
|
13
|
+
* data-extra-params — JSON-encoded object of additional query params
|
|
14
|
+
* data-hide-pagination — CSS selector of no-JS pagination to hide
|
|
4
15
|
*/
|
|
5
16
|
|
|
6
17
|
document.addEventListener("alpine:init", () => {
|
|
7
18
|
// eslint-disable-next-line no-undef
|
|
8
|
-
Alpine.data("
|
|
19
|
+
Alpine.data("apInfiniteScroll", () => ({
|
|
9
20
|
loading: false,
|
|
10
21
|
done: false,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
22
|
+
cursor: null,
|
|
23
|
+
apiUrl: "",
|
|
24
|
+
cursorParam: "before",
|
|
25
|
+
cursorField: "before",
|
|
26
|
+
timelineId: "",
|
|
27
|
+
extraParams: {},
|
|
15
28
|
observer: null,
|
|
16
29
|
|
|
17
30
|
init() {
|
|
18
31
|
const el = this.$el;
|
|
19
|
-
this.
|
|
20
|
-
this.
|
|
21
|
-
this.
|
|
22
|
-
this.
|
|
23
|
-
|
|
24
|
-
if (!this.maxId) {
|
|
25
|
-
this.done = true;
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
this.observer = new IntersectionObserver(
|
|
30
|
-
(entries) => {
|
|
31
|
-
for (const entry of entries) {
|
|
32
|
-
if (entry.isIntersecting && !this.loading && !this.done) {
|
|
33
|
-
this.loadMore();
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
},
|
|
37
|
-
{ rootMargin: "200px" }
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
if (this.$refs.sentinel) {
|
|
41
|
-
this.observer.observe(this.$refs.sentinel);
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
|
-
|
|
45
|
-
async loadMore() {
|
|
46
|
-
if (this.loading || this.done || !this.maxId) return;
|
|
47
|
-
|
|
48
|
-
this.loading = true;
|
|
49
|
-
|
|
50
|
-
const timeline = document.getElementById("ap-explore-timeline");
|
|
51
|
-
const mountPath = timeline ? timeline.dataset.mountPath : "";
|
|
52
|
-
|
|
53
|
-
const params = new URLSearchParams({
|
|
54
|
-
instance: this.instance,
|
|
55
|
-
scope: this.scope,
|
|
56
|
-
max_id: this.maxId,
|
|
57
|
-
});
|
|
58
|
-
// Pass hashtag when in hashtag mode so infinite scroll stays on tag timeline
|
|
59
|
-
if (this.hashtag) {
|
|
60
|
-
params.set("hashtag", this.hashtag);
|
|
61
|
-
}
|
|
32
|
+
this.cursor = el.dataset.cursor || null;
|
|
33
|
+
this.apiUrl = el.dataset.apiUrl || "";
|
|
34
|
+
this.cursorParam = el.dataset.cursorParam || "before";
|
|
35
|
+
this.cursorField = el.dataset.cursorField || "before";
|
|
36
|
+
this.timelineId = el.dataset.timelineId || "";
|
|
62
37
|
|
|
38
|
+
// Parse extra params from JSON data attribute
|
|
63
39
|
try {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
70
|
-
|
|
71
|
-
const data = await res.json();
|
|
72
|
-
|
|
73
|
-
if (data.html && timeline) {
|
|
74
|
-
timeline.insertAdjacentHTML("beforeend", data.html);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (data.maxId) {
|
|
78
|
-
this.maxId = data.maxId;
|
|
79
|
-
} else {
|
|
80
|
-
this.done = true;
|
|
81
|
-
if (this.observer) this.observer.disconnect();
|
|
82
|
-
}
|
|
83
|
-
} catch (err) {
|
|
84
|
-
console.error("[ap-explore-scroll] load failed:", err.message);
|
|
85
|
-
} finally {
|
|
86
|
-
this.loading = false;
|
|
40
|
+
this.extraParams = JSON.parse(el.dataset.extraParams || "{}");
|
|
41
|
+
} catch {
|
|
42
|
+
this.extraParams = {};
|
|
87
43
|
}
|
|
88
|
-
},
|
|
89
|
-
|
|
90
|
-
destroy() {
|
|
91
|
-
if (this.observer) this.observer.disconnect();
|
|
92
|
-
},
|
|
93
|
-
}));
|
|
94
|
-
|
|
95
|
-
// eslint-disable-next-line no-undef
|
|
96
|
-
Alpine.data("apInfiniteScroll", () => ({
|
|
97
|
-
loading: false,
|
|
98
|
-
done: false,
|
|
99
|
-
before: null,
|
|
100
|
-
tab: "",
|
|
101
|
-
tag: "",
|
|
102
|
-
observer: null,
|
|
103
|
-
|
|
104
|
-
init() {
|
|
105
|
-
const el = this.$el;
|
|
106
|
-
this.before = el.dataset.before || null;
|
|
107
|
-
this.tab = el.dataset.tab || "";
|
|
108
|
-
this.tag = el.dataset.tag || "";
|
|
109
44
|
|
|
110
45
|
// Hide the no-JS pagination fallback now that JS is active
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
document.getElementById(
|
|
114
|
-
|
|
115
|
-
paginationEl.style.display = "none";
|
|
46
|
+
const hideSel = el.dataset.hidePagination;
|
|
47
|
+
if (hideSel) {
|
|
48
|
+
const paginationEl = document.getElementById(hideSel);
|
|
49
|
+
if (paginationEl) paginationEl.style.display = "none";
|
|
116
50
|
}
|
|
117
51
|
|
|
118
|
-
if (!this.
|
|
52
|
+
if (!this.cursor) {
|
|
119
53
|
this.done = true;
|
|
120
54
|
return;
|
|
121
55
|
}
|
|
@@ -129,7 +63,7 @@ document.addEventListener("alpine:init", () => {
|
|
|
129
63
|
}
|
|
130
64
|
}
|
|
131
65
|
},
|
|
132
|
-
{ rootMargin: "200px" }
|
|
66
|
+
{ rootMargin: "200px" },
|
|
133
67
|
);
|
|
134
68
|
|
|
135
69
|
if (this.$refs.sentinel) {
|
|
@@ -138,36 +72,36 @@ document.addEventListener("alpine:init", () => {
|
|
|
138
72
|
},
|
|
139
73
|
|
|
140
74
|
async loadMore() {
|
|
141
|
-
if (this.loading || this.done || !this.
|
|
75
|
+
if (this.loading || this.done || !this.cursor) return;
|
|
142
76
|
|
|
143
77
|
this.loading = true;
|
|
144
78
|
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (this.tab) params.set("tab", this.tab);
|
|
150
|
-
if (this.tag) params.set("tag", this.tag);
|
|
79
|
+
const params = new URLSearchParams({
|
|
80
|
+
[this.cursorParam]: this.cursor,
|
|
81
|
+
...this.extraParams,
|
|
82
|
+
});
|
|
151
83
|
|
|
152
84
|
try {
|
|
153
85
|
const res = await fetch(
|
|
154
|
-
`${
|
|
155
|
-
{ headers: { Accept: "application/json" } }
|
|
86
|
+
`${this.apiUrl}?${params.toString()}`,
|
|
87
|
+
{ headers: { Accept: "application/json" } },
|
|
156
88
|
);
|
|
157
89
|
|
|
158
90
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
159
91
|
|
|
160
92
|
const data = await res.json();
|
|
161
93
|
|
|
94
|
+
const timeline = this.timelineId
|
|
95
|
+
? document.getElementById(this.timelineId)
|
|
96
|
+
: null;
|
|
97
|
+
|
|
162
98
|
if (data.html && timeline) {
|
|
163
|
-
// Append the returned pre-rendered HTML
|
|
164
99
|
timeline.insertAdjacentHTML("beforeend", data.html);
|
|
165
100
|
}
|
|
166
101
|
|
|
167
|
-
if (data.
|
|
168
|
-
this.
|
|
102
|
+
if (data[this.cursorField]) {
|
|
103
|
+
this.cursor = data[this.cursorField];
|
|
169
104
|
} else {
|
|
170
|
-
// No more items
|
|
171
105
|
this.done = true;
|
|
172
106
|
if (this.observer) this.observer.disconnect();
|
|
173
107
|
}
|
|
@@ -178,10 +112,6 @@ document.addEventListener("alpine:init", () => {
|
|
|
178
112
|
}
|
|
179
113
|
},
|
|
180
114
|
|
|
181
|
-
appendItems(/* detail */) {
|
|
182
|
-
// Custom event hook — not used in this implementation but kept for extensibility
|
|
183
|
-
},
|
|
184
|
-
|
|
185
115
|
destroy() {
|
|
186
116
|
if (this.observer) this.observer.disconnect();
|
|
187
117
|
},
|
|
@@ -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);
|
|
@@ -2528,3 +2536,54 @@
|
|
|
2528
2536
|
padding: var(--space-s) 0 var(--space-xs);
|
|
2529
2537
|
}
|
|
2530
2538
|
|
|
2539
|
+
/* Custom emoji */
|
|
2540
|
+
.ap-custom-emoji {
|
|
2541
|
+
height: 1.2em;
|
|
2542
|
+
width: auto;
|
|
2543
|
+
vertical-align: middle;
|
|
2544
|
+
display: inline;
|
|
2545
|
+
margin: 0 0.05em;
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
/* Gallery items — positioned for ALT badge overlay */
|
|
2549
|
+
.ap-card__gallery-item {
|
|
2550
|
+
position: relative;
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
/* ALT text badges */
|
|
2554
|
+
.ap-media__alt-badge {
|
|
2555
|
+
position: absolute;
|
|
2556
|
+
bottom: 0.5rem;
|
|
2557
|
+
left: 0.5rem;
|
|
2558
|
+
background: rgba(0, 0, 0, 0.7);
|
|
2559
|
+
color: white;
|
|
2560
|
+
font-size: 0.65rem;
|
|
2561
|
+
font-weight: 700;
|
|
2562
|
+
padding: 0.15rem 0.35rem;
|
|
2563
|
+
border-radius: var(--border-radius-small);
|
|
2564
|
+
border: none;
|
|
2565
|
+
cursor: pointer;
|
|
2566
|
+
text-transform: uppercase;
|
|
2567
|
+
letter-spacing: 0.03em;
|
|
2568
|
+
z-index: 1;
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
.ap-media__alt-badge:hover {
|
|
2572
|
+
background: rgba(0, 0, 0, 0.9);
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
.ap-media__alt-text {
|
|
2576
|
+
position: absolute;
|
|
2577
|
+
bottom: 2.2rem;
|
|
2578
|
+
left: 0.5rem;
|
|
2579
|
+
right: 0.5rem;
|
|
2580
|
+
background: rgba(0, 0, 0, 0.85);
|
|
2581
|
+
color: white;
|
|
2582
|
+
font-size: var(--font-size-s);
|
|
2583
|
+
padding: 0.5rem;
|
|
2584
|
+
border-radius: var(--border-radius-small);
|
|
2585
|
+
max-height: 8rem;
|
|
2586
|
+
overflow-y: auto;
|
|
2587
|
+
z-index: 2;
|
|
2588
|
+
}
|
|
2589
|
+
|
|
@@ -4,13 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { getTimelineItems, countNewItems, markItemsRead } from "../storage/timeline.js";
|
|
6
6
|
import { getToken, validateToken } from "../csrf.js";
|
|
7
|
-
import {
|
|
8
|
-
getMutedUrls,
|
|
9
|
-
getMutedKeywords,
|
|
10
|
-
getBlockedUrls,
|
|
11
|
-
getFilterMode,
|
|
12
|
-
} from "../storage/moderation.js";
|
|
13
|
-
import { stripQuoteReferenceHtml } from "../og-unfurl.js";
|
|
7
|
+
import { postProcessItems, applyTabFilter, loadModerationData, renderItemCards } from "../item-processing.js";
|
|
14
8
|
|
|
15
9
|
export function apiTimelineController(mountPath) {
|
|
16
10
|
return async (request, response, next) => {
|
|
@@ -26,7 +20,7 @@ export function apiTimelineController(mountPath) {
|
|
|
26
20
|
const before = request.query.before;
|
|
27
21
|
const limit = 20;
|
|
28
22
|
|
|
29
|
-
// Build storage query options
|
|
23
|
+
// Build storage query options
|
|
30
24
|
const unread = request.query.unread === "1";
|
|
31
25
|
const options = { before, limit, unread };
|
|
32
26
|
|
|
@@ -45,132 +39,32 @@ export function apiTimelineController(mountPath) {
|
|
|
45
39
|
|
|
46
40
|
const result = await getTimelineItems(collections, options);
|
|
47
41
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
if (!tag) {
|
|
51
|
-
if (tab === "replies") {
|
|
52
|
-
items = items.filter((item) => item.inReplyTo);
|
|
53
|
-
} else if (tab === "media") {
|
|
54
|
-
items = items.filter(
|
|
55
|
-
(item) =>
|
|
56
|
-
(item.photo && item.photo.length > 0) ||
|
|
57
|
-
(item.video && item.video.length > 0) ||
|
|
58
|
-
(item.audio && item.audio.length > 0)
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
42
|
+
// Tab filtering for types not supported by storage layer
|
|
43
|
+
const tabFiltered = tag ? result.items : applyTabFilter(result.items, tab);
|
|
62
44
|
|
|
63
|
-
//
|
|
45
|
+
// Shared processing pipeline: moderation, quote stripping, interactions
|
|
64
46
|
const modCollections = {
|
|
65
47
|
ap_muted: application?.collections?.get("ap_muted"),
|
|
66
48
|
ap_blocked: application?.collections?.get("ap_blocked"),
|
|
67
49
|
ap_profile: application?.collections?.get("ap_profile"),
|
|
68
50
|
};
|
|
69
|
-
const
|
|
70
|
-
await Promise.all([
|
|
71
|
-
getMutedUrls(modCollections),
|
|
72
|
-
getMutedKeywords(modCollections),
|
|
73
|
-
getBlockedUrls(modCollections),
|
|
74
|
-
getFilterMode(modCollections),
|
|
75
|
-
]);
|
|
76
|
-
const blockedSet = new Set(blockedUrls);
|
|
77
|
-
const mutedSet = new Set(mutedUrls);
|
|
78
|
-
|
|
79
|
-
if (blockedSet.size > 0 || mutedSet.size > 0 || mutedKeywords.length > 0) {
|
|
80
|
-
items = items.filter((item) => {
|
|
81
|
-
if (item.author?.url && blockedSet.has(item.author.url)) {
|
|
82
|
-
return false;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const isMutedActor = item.author?.url && mutedSet.has(item.author.url);
|
|
86
|
-
let matchedKeyword = null;
|
|
87
|
-
if (mutedKeywords.length > 0) {
|
|
88
|
-
const searchable = [item.content?.text, item.name, item.summary]
|
|
89
|
-
.filter(Boolean)
|
|
90
|
-
.join(" ")
|
|
91
|
-
.toLowerCase();
|
|
92
|
-
if (searchable) {
|
|
93
|
-
matchedKeyword = mutedKeywords.find((kw) =>
|
|
94
|
-
searchable.includes(kw.toLowerCase())
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (isMutedActor || matchedKeyword) {
|
|
100
|
-
if (filterMode === "warn") {
|
|
101
|
-
item._moderated = true;
|
|
102
|
-
item._moderationReason = isMutedActor ? "muted_account" : "muted_keyword";
|
|
103
|
-
if (matchedKeyword) item._moderationKeyword = matchedKeyword;
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return true;
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Get interaction state
|
|
114
|
-
const interactionsCol = application?.collections?.get("ap_interactions");
|
|
115
|
-
const interactionMap = {};
|
|
116
|
-
|
|
117
|
-
if (interactionsCol) {
|
|
118
|
-
const lookupUrls = new Set();
|
|
119
|
-
const objectUrlToUid = new Map();
|
|
120
|
-
for (const item of items) {
|
|
121
|
-
const uid = item.uid;
|
|
122
|
-
const displayUrl = item.url || item.originalUrl;
|
|
123
|
-
if (uid) { lookupUrls.add(uid); objectUrlToUid.set(uid, uid); }
|
|
124
|
-
if (displayUrl) { lookupUrls.add(displayUrl); objectUrlToUid.set(displayUrl, uid || displayUrl); }
|
|
125
|
-
}
|
|
126
|
-
if (lookupUrls.size > 0) {
|
|
127
|
-
const interactions = await interactionsCol
|
|
128
|
-
.find({ objectUrl: { $in: [...lookupUrls] } })
|
|
129
|
-
.toArray();
|
|
130
|
-
for (const interaction of interactions) {
|
|
131
|
-
const key = objectUrlToUid.get(interaction.objectUrl) || interaction.objectUrl;
|
|
132
|
-
if (!interactionMap[key]) interactionMap[key] = {};
|
|
133
|
-
interactionMap[key][interaction.type] = true;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
51
|
+
const moderation = await loadModerationData(modCollections);
|
|
137
52
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
53
|
+
const { items, interactionMap } = await postProcessItems(tabFiltered, {
|
|
54
|
+
moderation,
|
|
55
|
+
interactionsCol: application?.collections?.get("ap_interactions"),
|
|
56
|
+
});
|
|
145
57
|
|
|
146
58
|
const csrfToken = getToken(request.session);
|
|
147
|
-
|
|
148
|
-
// Render each card server-side using the same Nunjucks template
|
|
149
|
-
// Merge response.locals so that i18n (__), mountPath, etc. are available
|
|
150
|
-
const templateData = {
|
|
59
|
+
const html = await renderItemCards(items, request, {
|
|
151
60
|
...response.locals,
|
|
152
61
|
mountPath,
|
|
153
62
|
csrfToken,
|
|
154
63
|
interactionMap,
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
const htmlParts = await Promise.all(
|
|
158
|
-
items.map((item) => {
|
|
159
|
-
return new Promise((resolve, reject) => {
|
|
160
|
-
request.app.render(
|
|
161
|
-
"partials/ap-item-card.njk",
|
|
162
|
-
{ ...templateData, item },
|
|
163
|
-
(err, html) => {
|
|
164
|
-
if (err) reject(err);
|
|
165
|
-
else resolve(html);
|
|
166
|
-
}
|
|
167
|
-
);
|
|
168
|
-
});
|
|
169
|
-
})
|
|
170
|
-
);
|
|
64
|
+
});
|
|
171
65
|
|
|
172
66
|
response.json({
|
|
173
|
-
html
|
|
67
|
+
html,
|
|
174
68
|
before: result.before,
|
|
175
69
|
});
|
|
176
70
|
} catch (error) {
|
|
@@ -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
|
|