@rmdes/indiekit-endpoint-activitypub 2.0.27 → 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.
@@ -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);