@rmdes/indiekit-endpoint-activitypub 2.0.26 → 2.0.28
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 +56 -1
- package/assets/reader-infinite-scroll.js +183 -0
- package/assets/reader.css +282 -0
- package/index.js +34 -0
- package/lib/controllers/api-timeline.js +170 -0
- package/lib/controllers/explore.js +293 -0
- package/lib/controllers/follow-tag.js +62 -0
- package/lib/controllers/reader.js +11 -0
- package/lib/controllers/tag-timeline.js +147 -0
- package/lib/inbox-listeners.js +40 -3
- package/lib/migrations/separate-mentions.js +88 -0
- package/lib/storage/followed-tags.js +65 -0
- package/lib/storage/timeline.js +15 -2
- package/lib/timeline-store.js +18 -5
- package/locales/en.json +27 -4
- package/package.json +1 -1
- package/views/activitypub-explore.njk +82 -0
- package/views/activitypub-reader.njk +42 -3
- package/views/activitypub-tag-timeline.njk +86 -0
- package/views/layouts/ap-reader.njk +4 -1
- package/views/partials/ap-item-card.njk +20 -5
package/README.md
CHANGED
|
@@ -268,11 +268,66 @@ The JF2-to-ActivityStreams converter handles these Indiekit post types:
|
|
|
268
268
|
|
|
269
269
|
Categories are converted to `Hashtag` tags. Bookmarks include a bookmark emoji and link.
|
|
270
270
|
|
|
271
|
+
## Fedify Workarounds and Implementation Notes
|
|
272
|
+
|
|
273
|
+
This plugin uses [Fedify](https://fedify.dev) 2.0 but carries several workarounds for issues in Fedify or its Express integration. These are documented here so they can be revisited when Fedify upgrades.
|
|
274
|
+
|
|
275
|
+
### Custom Express Bridge (instead of `@fedify/express`)
|
|
276
|
+
|
|
277
|
+
**File:** `lib/federation-bridge.js`
|
|
278
|
+
**Upstream issue:** `@fedify/express` uses `req.url` ([source](https://github.com/fedify-dev/fedify/blob/main/packages/express/src/index.ts), line 73), not `req.originalUrl`.
|
|
279
|
+
|
|
280
|
+
Indiekit plugins mount at a sub-path (e.g. `/activitypub`). Express strips the mount prefix from `req.url`, so Fedify's URI template matching breaks — WebFinger, actor endpoints, and inbox all return 404. The custom bridge uses `req.originalUrl` to preserve the full path.
|
|
281
|
+
|
|
282
|
+
The bridge also reconstructs POST bodies that Express's body parser has already consumed (`req.readable === false`). Without this, Fedify handlers like the `@fedify/debugger` login form receive empty bodies.
|
|
283
|
+
|
|
284
|
+
**Revisit when:** `@fedify/express` switches to `req.originalUrl`, or provides an option to pass a custom URL builder.
|
|
285
|
+
|
|
286
|
+
### JSON-LD Attachment Array Compaction
|
|
287
|
+
|
|
288
|
+
**File:** `lib/federation-bridge.js` (in `sendFedifyResponse()`)
|
|
289
|
+
**Upstream issue:** JSON-LD compaction collapses single-element arrays to plain objects.
|
|
290
|
+
|
|
291
|
+
Mastodon's `update_account_fields` checks `attachment.is_a?(Array)` and silently skips profile links (PropertyValues) when `attachment` is a plain object instead of an array. The bridge intercepts actor JSON-LD responses and forces `attachment` to always be an array.
|
|
292
|
+
|
|
293
|
+
**Revisit when:** Fedify adds an option to preserve arrays during JSON-LD serialization, or Mastodon fixes their array check.
|
|
294
|
+
|
|
295
|
+
### `.authorize()` Not Chained on Actor Dispatcher
|
|
296
|
+
|
|
297
|
+
**File:** `lib/federation-setup.js` (line ~254)
|
|
298
|
+
**Upstream issue:** No authenticated document loading for outgoing key fetches during signature verification.
|
|
299
|
+
|
|
300
|
+
Fedify's `.authorize()` predicate triggers HTTP Signature verification on every GET to the actor endpoint. When a remote server that requires Authorized Fetch (e.g. kobolds.online) requests our actor, Fedify tries to fetch *their* public key to verify the signature. Those servers return 401 on unsigned GETs, causing uncaught `FetchError` and 500 responses.
|
|
301
|
+
|
|
302
|
+
This means we do **not** enforce Authorized Fetch on our actor endpoint. Any server can read our actor document without signing the request.
|
|
303
|
+
|
|
304
|
+
**Revisit when:** Fedify supports using the instance actor's keys for outgoing document fetches during signature verification (i.e. authenticated document loading in the verification path, not just in inbox handlers).
|
|
305
|
+
|
|
306
|
+
### `importSpkiPem()` / `importPkcs8Pem()` — Local PEM Import
|
|
307
|
+
|
|
308
|
+
**File:** `lib/federation-setup.js` (lines ~784–816)
|
|
309
|
+
**Upstream change:** Fedify 1.x exported `importSpki()` for loading PEM public keys. This was removed in Fedify 2.0.
|
|
310
|
+
|
|
311
|
+
The plugin carries local `importSpkiPem()` and `importPkcs8Pem()` functions that use the Web Crypto API directly (`crypto.subtle.importKey`) to load legacy RSA key pairs stored in MongoDB from the Fedify 1.x era. New key pairs are generated using Fedify 2.0's `generateCryptoKeyPair()` and stored as JWK, so these functions only matter for existing installations that migrated from Fedify 1.x.
|
|
312
|
+
|
|
313
|
+
**Revisit when:** All existing installations have been migrated to JWK-stored keys, or Fedify re-exports a PEM import utility.
|
|
314
|
+
|
|
315
|
+
### Authenticated Document Loader for Inbox Handlers
|
|
316
|
+
|
|
317
|
+
**File:** `lib/inbox-listeners.js`
|
|
318
|
+
**Upstream behavior:** Fedify's personal inbox handlers do not automatically use authenticated (signed) HTTP fetches.
|
|
319
|
+
|
|
320
|
+
All `.getActor()`, `.getObject()`, and `.getTarget()` calls in inbox handlers must explicitly pass an authenticated `DocumentLoader` obtained via `ctx.getDocumentLoader({ identifier: handle })`. Without this, fetches to Authorized Fetch (Secure Mode) servers like hachyderm.io fail with 401, causing timeline items to show "Unknown" authors and missing content.
|
|
321
|
+
|
|
322
|
+
This is not a bug — Fedify requires explicit opt-in for signed fetches. But it's a pattern that every inbox handler must follow, and forgetting it silently degrades functionality.
|
|
323
|
+
|
|
324
|
+
**Revisit when:** Fedify provides an option to default to authenticated fetches in inbox handler context, or adds a middleware layer that handles this automatically.
|
|
325
|
+
|
|
271
326
|
## Known Limitations
|
|
272
327
|
|
|
273
328
|
- **No automated tests** — Manual testing against real fediverse servers
|
|
274
329
|
- **Single actor** — One fediverse identity per Indiekit instance
|
|
275
|
-
- **No Authorized Fetch enforcement** —
|
|
330
|
+
- **No Authorized Fetch enforcement** — `.authorize()` disabled on actor dispatcher (see workarounds above)
|
|
276
331
|
- **No image upload in reader** — Compose form is text-only
|
|
277
332
|
- **In-process queue without Redis** — Activities may be lost on restart
|
|
278
333
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infinite scroll — AlpineJS component for AJAX load-more on the timeline
|
|
3
|
+
* Registers the `apInfiniteScroll` Alpine data component.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
document.addEventListener("alpine:init", () => {
|
|
7
|
+
// eslint-disable-next-line no-undef
|
|
8
|
+
Alpine.data("apExploreScroll", () => ({
|
|
9
|
+
loading: false,
|
|
10
|
+
done: false,
|
|
11
|
+
maxId: null,
|
|
12
|
+
instance: "",
|
|
13
|
+
scope: "local",
|
|
14
|
+
observer: null,
|
|
15
|
+
|
|
16
|
+
init() {
|
|
17
|
+
const el = this.$el;
|
|
18
|
+
this.maxId = el.dataset.maxId || null;
|
|
19
|
+
this.instance = el.dataset.instance || "";
|
|
20
|
+
this.scope = el.dataset.scope || "local";
|
|
21
|
+
|
|
22
|
+
if (!this.maxId) {
|
|
23
|
+
this.done = true;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
this.observer = new IntersectionObserver(
|
|
28
|
+
(entries) => {
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (entry.isIntersecting && !this.loading && !this.done) {
|
|
31
|
+
this.loadMore();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{ rootMargin: "200px" }
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
if (this.$refs.sentinel) {
|
|
39
|
+
this.observer.observe(this.$refs.sentinel);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async loadMore() {
|
|
44
|
+
if (this.loading || this.done || !this.maxId) return;
|
|
45
|
+
|
|
46
|
+
this.loading = true;
|
|
47
|
+
|
|
48
|
+
const timeline = document.getElementById("ap-explore-timeline");
|
|
49
|
+
const mountPath = timeline ? timeline.dataset.mountPath : "";
|
|
50
|
+
|
|
51
|
+
const params = new URLSearchParams({
|
|
52
|
+
instance: this.instance,
|
|
53
|
+
scope: this.scope,
|
|
54
|
+
max_id: this.maxId,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(
|
|
59
|
+
`${mountPath}/admin/reader/api/explore?${params.toString()}`,
|
|
60
|
+
{ headers: { Accept: "application/json" } }
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
64
|
+
|
|
65
|
+
const data = await res.json();
|
|
66
|
+
|
|
67
|
+
if (data.html && timeline) {
|
|
68
|
+
timeline.insertAdjacentHTML("beforeend", data.html);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (data.maxId) {
|
|
72
|
+
this.maxId = data.maxId;
|
|
73
|
+
} else {
|
|
74
|
+
this.done = true;
|
|
75
|
+
if (this.observer) this.observer.disconnect();
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error("[ap-explore-scroll] load failed:", err.message);
|
|
79
|
+
} finally {
|
|
80
|
+
this.loading = false;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
destroy() {
|
|
85
|
+
if (this.observer) this.observer.disconnect();
|
|
86
|
+
},
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
// eslint-disable-next-line no-undef
|
|
90
|
+
Alpine.data("apInfiniteScroll", () => ({
|
|
91
|
+
loading: false,
|
|
92
|
+
done: false,
|
|
93
|
+
before: null,
|
|
94
|
+
tab: "",
|
|
95
|
+
tag: "",
|
|
96
|
+
observer: null,
|
|
97
|
+
|
|
98
|
+
init() {
|
|
99
|
+
const el = this.$el;
|
|
100
|
+
this.before = el.dataset.before || null;
|
|
101
|
+
this.tab = el.dataset.tab || "";
|
|
102
|
+
this.tag = el.dataset.tag || "";
|
|
103
|
+
|
|
104
|
+
// Hide the no-JS pagination fallback now that JS is active
|
|
105
|
+
const paginationEl =
|
|
106
|
+
document.getElementById("ap-reader-pagination") ||
|
|
107
|
+
document.getElementById("ap-tag-pagination");
|
|
108
|
+
if (paginationEl) {
|
|
109
|
+
paginationEl.style.display = "none";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!this.before) {
|
|
113
|
+
this.done = true;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Set up IntersectionObserver to auto-load when sentinel comes into view
|
|
118
|
+
this.observer = new IntersectionObserver(
|
|
119
|
+
(entries) => {
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (entry.isIntersecting && !this.loading && !this.done) {
|
|
122
|
+
this.loadMore();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
{ rootMargin: "200px" }
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
if (this.$refs.sentinel) {
|
|
130
|
+
this.observer.observe(this.$refs.sentinel);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async loadMore() {
|
|
135
|
+
if (this.loading || this.done || !this.before) return;
|
|
136
|
+
|
|
137
|
+
this.loading = true;
|
|
138
|
+
|
|
139
|
+
const timeline = document.getElementById("ap-timeline");
|
|
140
|
+
const mountPath = timeline ? timeline.dataset.mountPath : "";
|
|
141
|
+
|
|
142
|
+
const params = new URLSearchParams({ before: this.before });
|
|
143
|
+
if (this.tab) params.set("tab", this.tab);
|
|
144
|
+
if (this.tag) params.set("tag", this.tag);
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const res = await fetch(
|
|
148
|
+
`${mountPath}/admin/reader/api/timeline?${params.toString()}`,
|
|
149
|
+
{ headers: { Accept: "application/json" } }
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
153
|
+
|
|
154
|
+
const data = await res.json();
|
|
155
|
+
|
|
156
|
+
if (data.html && timeline) {
|
|
157
|
+
// Append the returned pre-rendered HTML
|
|
158
|
+
timeline.insertAdjacentHTML("beforeend", data.html);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (data.before) {
|
|
162
|
+
this.before = data.before;
|
|
163
|
+
} else {
|
|
164
|
+
// No more items
|
|
165
|
+
this.done = true;
|
|
166
|
+
if (this.observer) this.observer.disconnect();
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.error("[ap-infinite-scroll] load failed:", err.message);
|
|
170
|
+
} finally {
|
|
171
|
+
this.loading = false;
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
appendItems(/* detail */) {
|
|
176
|
+
// Custom event hook — not used in this implementation but kept for extensibility
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
destroy() {
|
|
180
|
+
if (this.observer) this.observer.disconnect();
|
|
181
|
+
},
|
|
182
|
+
}));
|
|
183
|
+
});
|
package/assets/reader.css
CHANGED
|
@@ -655,6 +655,25 @@
|
|
|
655
655
|
color: var(--color-on-background);
|
|
656
656
|
}
|
|
657
657
|
|
|
658
|
+
.ap-card__mention {
|
|
659
|
+
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
|
660
|
+
border-radius: var(--border-radius-large);
|
|
661
|
+
color: var(--color-accent);
|
|
662
|
+
font-size: var(--font-size-s);
|
|
663
|
+
padding: 2px var(--space-xs);
|
|
664
|
+
text-decoration: none;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
.ap-card__mention:hover {
|
|
668
|
+
background: color-mix(in srgb, var(--color-accent) 22%, transparent);
|
|
669
|
+
color: var(--color-accent);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.ap-card__mention--legacy {
|
|
673
|
+
cursor: default;
|
|
674
|
+
opacity: 0.7;
|
|
675
|
+
}
|
|
676
|
+
|
|
658
677
|
/* ==========================================================================
|
|
659
678
|
Interaction Buttons
|
|
660
679
|
========================================================================== */
|
|
@@ -735,6 +754,55 @@
|
|
|
735
754
|
text-decoration: underline;
|
|
736
755
|
}
|
|
737
756
|
|
|
757
|
+
/* Hidden once Alpine is active (JS replaces with infinite scroll) */
|
|
758
|
+
.ap-pagination--js-hidden {
|
|
759
|
+
/* Shown by default for no-JS fallback — Alpine hides via display:none */
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/* ==========================================================================
|
|
763
|
+
Infinite Scroll / Load More
|
|
764
|
+
========================================================================== */
|
|
765
|
+
|
|
766
|
+
.ap-load-more {
|
|
767
|
+
display: flex;
|
|
768
|
+
flex-direction: column;
|
|
769
|
+
align-items: center;
|
|
770
|
+
gap: var(--space-s);
|
|
771
|
+
padding: var(--space-m) 0;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.ap-load-more__sentinel {
|
|
775
|
+
height: 1px;
|
|
776
|
+
width: 100%;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
.ap-load-more__btn {
|
|
780
|
+
background: var(--color-offset);
|
|
781
|
+
border: var(--border-width-thin) solid var(--color-outline);
|
|
782
|
+
border-radius: var(--border-radius-small);
|
|
783
|
+
color: var(--color-on-background);
|
|
784
|
+
cursor: pointer;
|
|
785
|
+
font-size: var(--font-size-s);
|
|
786
|
+
padding: var(--space-xs) var(--space-m);
|
|
787
|
+
transition: background 0.15s;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
.ap-load-more__btn:hover:not(:disabled) {
|
|
791
|
+
background: var(--color-offset-variant);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
.ap-load-more__btn:disabled {
|
|
795
|
+
cursor: wait;
|
|
796
|
+
opacity: 0.6;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
.ap-load-more__done {
|
|
800
|
+
color: var(--color-on-offset);
|
|
801
|
+
font-size: var(--font-size-s);
|
|
802
|
+
margin: 0;
|
|
803
|
+
text-align: center;
|
|
804
|
+
}
|
|
805
|
+
|
|
738
806
|
/* ==========================================================================
|
|
739
807
|
Compose Form
|
|
740
808
|
========================================================================== */
|
|
@@ -1572,6 +1640,204 @@
|
|
|
1572
1640
|
box-shadow: 0 0 0 1px var(--color-primary);
|
|
1573
1641
|
}
|
|
1574
1642
|
|
|
1643
|
+
/* ==========================================================================
|
|
1644
|
+
Tag Timeline Header
|
|
1645
|
+
========================================================================== */
|
|
1646
|
+
|
|
1647
|
+
.ap-tag-header {
|
|
1648
|
+
align-items: flex-start;
|
|
1649
|
+
background: var(--color-offset);
|
|
1650
|
+
border-bottom: var(--border-width-thin) solid var(--color-outline);
|
|
1651
|
+
border-radius: var(--border-radius-small);
|
|
1652
|
+
display: flex;
|
|
1653
|
+
gap: var(--space-m);
|
|
1654
|
+
justify-content: space-between;
|
|
1655
|
+
margin-bottom: var(--space-m);
|
|
1656
|
+
padding: var(--space-m);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
.ap-tag-header__title {
|
|
1660
|
+
font-size: var(--font-size-xl);
|
|
1661
|
+
font-weight: var(--font-weight-bold);
|
|
1662
|
+
margin: 0 0 var(--space-xs);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
.ap-tag-header__count {
|
|
1666
|
+
color: var(--color-on-offset);
|
|
1667
|
+
font-size: var(--font-size-s);
|
|
1668
|
+
margin: 0;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
.ap-tag-header__actions {
|
|
1672
|
+
align-items: center;
|
|
1673
|
+
display: flex;
|
|
1674
|
+
flex-shrink: 0;
|
|
1675
|
+
gap: var(--space-s);
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
.ap-tag-header__follow-btn {
|
|
1679
|
+
background: var(--color-accent);
|
|
1680
|
+
border: none;
|
|
1681
|
+
border-radius: var(--border-radius-small);
|
|
1682
|
+
color: var(--color-on-accent);
|
|
1683
|
+
cursor: pointer;
|
|
1684
|
+
font-size: var(--font-size-s);
|
|
1685
|
+
padding: var(--space-xs) var(--space-s);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
.ap-tag-header__follow-btn:hover {
|
|
1689
|
+
opacity: 0.85;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
.ap-tag-header__unfollow-btn {
|
|
1693
|
+
background: transparent;
|
|
1694
|
+
border: var(--border-width-thin) solid var(--color-outline);
|
|
1695
|
+
border-radius: var(--border-radius-small);
|
|
1696
|
+
color: var(--color-on-background);
|
|
1697
|
+
cursor: pointer;
|
|
1698
|
+
font-size: var(--font-size-s);
|
|
1699
|
+
padding: var(--space-xs) var(--space-s);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
.ap-tag-header__unfollow-btn:hover {
|
|
1703
|
+
border-color: var(--color-on-background);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
.ap-tag-header__back {
|
|
1707
|
+
color: var(--color-on-offset);
|
|
1708
|
+
font-size: var(--font-size-s);
|
|
1709
|
+
text-decoration: none;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
.ap-tag-header__back:hover {
|
|
1713
|
+
color: var(--color-on-background);
|
|
1714
|
+
text-decoration: underline;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
@media (max-width: 640px) {
|
|
1718
|
+
.ap-tag-header {
|
|
1719
|
+
flex-direction: column;
|
|
1720
|
+
gap: var(--space-s);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
.ap-tag-header__actions {
|
|
1724
|
+
flex-wrap: wrap;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/* ==========================================================================
|
|
1729
|
+
Reader Tools Bar (Explore link, etc.)
|
|
1730
|
+
========================================================================== */
|
|
1731
|
+
|
|
1732
|
+
.ap-reader-tools {
|
|
1733
|
+
display: flex;
|
|
1734
|
+
gap: var(--space-s);
|
|
1735
|
+
justify-content: flex-end;
|
|
1736
|
+
margin-bottom: var(--space-s);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
.ap-reader-tools__explore {
|
|
1740
|
+
color: var(--color-on-offset);
|
|
1741
|
+
font-size: var(--font-size-s);
|
|
1742
|
+
text-decoration: none;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
.ap-reader-tools__explore:hover {
|
|
1746
|
+
color: var(--color-on-background);
|
|
1747
|
+
text-decoration: underline;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/* ==========================================================================
|
|
1751
|
+
Explore Page
|
|
1752
|
+
========================================================================== */
|
|
1753
|
+
|
|
1754
|
+
.ap-explore-header {
|
|
1755
|
+
margin-bottom: var(--space-m);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
.ap-explore-header__title {
|
|
1759
|
+
font-size: var(--font-size-xl);
|
|
1760
|
+
margin: 0 0 var(--space-xs);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
.ap-explore-header__desc {
|
|
1764
|
+
color: var(--color-on-offset);
|
|
1765
|
+
font-size: var(--font-size-s);
|
|
1766
|
+
margin: 0;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
.ap-explore-form {
|
|
1770
|
+
background: var(--color-offset);
|
|
1771
|
+
border: var(--border-width-thin) solid var(--color-outline);
|
|
1772
|
+
border-radius: var(--border-radius-small);
|
|
1773
|
+
margin-bottom: var(--space-m);
|
|
1774
|
+
padding: var(--space-m);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
.ap-explore-form__row {
|
|
1778
|
+
align-items: center;
|
|
1779
|
+
display: flex;
|
|
1780
|
+
gap: var(--space-s);
|
|
1781
|
+
flex-wrap: wrap;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
.ap-explore-form__input {
|
|
1785
|
+
border: var(--border-width-thin) solid var(--color-outline);
|
|
1786
|
+
border-radius: var(--border-radius-small);
|
|
1787
|
+
flex: 1;
|
|
1788
|
+
font-size: var(--font-size-base);
|
|
1789
|
+
min-width: 0;
|
|
1790
|
+
padding: var(--space-xs) var(--space-s);
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
.ap-explore-form__scope {
|
|
1794
|
+
display: flex;
|
|
1795
|
+
gap: var(--space-s);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
.ap-explore-form__scope-label {
|
|
1799
|
+
align-items: center;
|
|
1800
|
+
cursor: pointer;
|
|
1801
|
+
display: flex;
|
|
1802
|
+
font-size: var(--font-size-s);
|
|
1803
|
+
gap: var(--space-xs);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
.ap-explore-form__btn {
|
|
1807
|
+
background: var(--color-primary);
|
|
1808
|
+
border: none;
|
|
1809
|
+
border-radius: var(--border-radius-small);
|
|
1810
|
+
color: var(--color-on-primary);
|
|
1811
|
+
cursor: pointer;
|
|
1812
|
+
font-size: var(--font-size-s);
|
|
1813
|
+
padding: var(--space-xs) var(--space-m);
|
|
1814
|
+
white-space: nowrap;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
.ap-explore-form__btn:hover {
|
|
1818
|
+
opacity: 0.85;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
.ap-explore-error {
|
|
1822
|
+
background: color-mix(in srgb, var(--color-red45) 10%, transparent);
|
|
1823
|
+
border: var(--border-width-thin) solid var(--color-red45);
|
|
1824
|
+
border-radius: var(--border-radius-small);
|
|
1825
|
+
color: var(--color-red45);
|
|
1826
|
+
margin-bottom: var(--space-m);
|
|
1827
|
+
padding: var(--space-s) var(--space-m);
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
@media (max-width: 640px) {
|
|
1831
|
+
.ap-explore-form__row {
|
|
1832
|
+
flex-direction: column;
|
|
1833
|
+
align-items: stretch;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
.ap-explore-form__btn {
|
|
1837
|
+
width: 100%;
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1575
1841
|
/* Replies — indented from the other side */
|
|
1576
1842
|
.ap-post-detail__replies {
|
|
1577
1843
|
margin-left: var(--space-l);
|
|
@@ -1582,3 +1848,19 @@
|
|
|
1582
1848
|
padding-left: var(--space-m);
|
|
1583
1849
|
margin-bottom: var(--space-xs);
|
|
1584
1850
|
}
|
|
1851
|
+
|
|
1852
|
+
/* Followed tags bar */
|
|
1853
|
+
.ap-followed-tags {
|
|
1854
|
+
display: flex;
|
|
1855
|
+
flex-wrap: wrap;
|
|
1856
|
+
align-items: center;
|
|
1857
|
+
gap: var(--space-xs);
|
|
1858
|
+
padding: var(--space-xs) 0;
|
|
1859
|
+
margin-bottom: var(--space-s);
|
|
1860
|
+
font-size: var(--font-size-s);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
.ap-followed-tags__label {
|
|
1864
|
+
color: var(--color-on-offset);
|
|
1865
|
+
font-weight: 600;
|
|
1866
|
+
}
|
package/index.js
CHANGED
|
@@ -59,6 +59,10 @@ import {
|
|
|
59
59
|
featuredTagsRemoveController,
|
|
60
60
|
} from "./lib/controllers/featured-tags.js";
|
|
61
61
|
import { resolveController } from "./lib/controllers/resolve.js";
|
|
62
|
+
import { tagTimelineController } from "./lib/controllers/tag-timeline.js";
|
|
63
|
+
import { apiTimelineController } from "./lib/controllers/api-timeline.js";
|
|
64
|
+
import { exploreController, exploreApiController } from "./lib/controllers/explore.js";
|
|
65
|
+
import { followTagController, unfollowTagController } from "./lib/controllers/follow-tag.js";
|
|
62
66
|
import { publicProfileController } from "./lib/controllers/public-profile.js";
|
|
63
67
|
import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.js";
|
|
64
68
|
import { myProfileController } from "./lib/controllers/my-profile.js";
|
|
@@ -71,6 +75,7 @@ import {
|
|
|
71
75
|
import { startBatchRefollow } from "./lib/batch-refollow.js";
|
|
72
76
|
import { logActivity } from "./lib/activity-log.js";
|
|
73
77
|
import { scheduleCleanup } from "./lib/timeline-cleanup.js";
|
|
78
|
+
import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
|
|
74
79
|
|
|
75
80
|
const defaults = {
|
|
76
81
|
mountPath: "/activitypub",
|
|
@@ -218,6 +223,12 @@ export default class ActivityPubEndpoint {
|
|
|
218
223
|
|
|
219
224
|
router.get("/", dashboardController(mp));
|
|
220
225
|
router.get("/admin/reader", readerController(mp));
|
|
226
|
+
router.get("/admin/reader/tag", tagTimelineController(mp));
|
|
227
|
+
router.get("/admin/reader/api/timeline", apiTimelineController(mp));
|
|
228
|
+
router.get("/admin/reader/explore", exploreController(mp));
|
|
229
|
+
router.get("/admin/reader/api/explore", exploreApiController(mp));
|
|
230
|
+
router.post("/admin/reader/follow-tag", followTagController(mp));
|
|
231
|
+
router.post("/admin/reader/unfollow-tag", unfollowTagController(mp));
|
|
221
232
|
router.get("/admin/reader/notifications", notificationsController(mp));
|
|
222
233
|
router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp));
|
|
223
234
|
router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp));
|
|
@@ -855,6 +866,7 @@ export default class ActivityPubEndpoint {
|
|
|
855
866
|
Indiekit.addCollection("ap_blocked");
|
|
856
867
|
Indiekit.addCollection("ap_interactions");
|
|
857
868
|
Indiekit.addCollection("ap_notes");
|
|
869
|
+
Indiekit.addCollection("ap_followed_tags");
|
|
858
870
|
|
|
859
871
|
// Store collection references (posts resolved lazily)
|
|
860
872
|
const indiekitCollections = Indiekit.collections;
|
|
@@ -874,6 +886,7 @@ export default class ActivityPubEndpoint {
|
|
|
874
886
|
ap_blocked: indiekitCollections.get("ap_blocked"),
|
|
875
887
|
ap_interactions: indiekitCollections.get("ap_interactions"),
|
|
876
888
|
ap_notes: indiekitCollections.get("ap_notes"),
|
|
889
|
+
ap_followed_tags: indiekitCollections.get("ap_followed_tags"),
|
|
877
890
|
get posts() {
|
|
878
891
|
return indiekitCollections.get("posts");
|
|
879
892
|
},
|
|
@@ -985,6 +998,18 @@ export default class ActivityPubEndpoint {
|
|
|
985
998
|
{ type: 1 },
|
|
986
999
|
{ background: true },
|
|
987
1000
|
);
|
|
1001
|
+
|
|
1002
|
+
// Followed hashtags — unique on tag (case-insensitive via normalization at write time)
|
|
1003
|
+
this._collections.ap_followed_tags.createIndex(
|
|
1004
|
+
{ tag: 1 },
|
|
1005
|
+
{ unique: true, background: true },
|
|
1006
|
+
);
|
|
1007
|
+
|
|
1008
|
+
// Tag filtering index on timeline
|
|
1009
|
+
this._collections.ap_timeline.createIndex(
|
|
1010
|
+
{ category: 1, published: -1 },
|
|
1011
|
+
{ background: true },
|
|
1012
|
+
);
|
|
988
1013
|
} catch {
|
|
989
1014
|
// Index creation failed — collections not yet available.
|
|
990
1015
|
// Indexes already exist from previous startups; non-fatal.
|
|
@@ -1039,6 +1064,15 @@ export default class ActivityPubEndpoint {
|
|
|
1039
1064
|
});
|
|
1040
1065
|
}, 10_000);
|
|
1041
1066
|
|
|
1067
|
+
// Run one-time migrations (idempotent — safe to run on every startup)
|
|
1068
|
+
runSeparateMentionsMigration(this._collections).then(({ skipped, updated }) => {
|
|
1069
|
+
if (!skipped) {
|
|
1070
|
+
console.log(`[ActivityPub] Migration separate-mentions: updated ${updated} timeline items`);
|
|
1071
|
+
}
|
|
1072
|
+
}).catch((error) => {
|
|
1073
|
+
console.error("[ActivityPub] Migration separate-mentions failed:", error.message);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1042
1076
|
// Schedule timeline retention cleanup (runs on startup + every 24h)
|
|
1043
1077
|
if (this.options.timelineRetention > 0) {
|
|
1044
1078
|
scheduleCleanup(this._collections, this.options.timelineRetention);
|