@rmdes/indiekit-endpoint-activitypub 2.0.36 → 2.1.1

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.
@@ -11,6 +11,7 @@ document.addEventListener("alpine:init", () => {
11
11
  maxId: null,
12
12
  instance: "",
13
13
  scope: "local",
14
+ hashtag: "",
14
15
  observer: null,
15
16
 
16
17
  init() {
@@ -18,6 +19,7 @@ document.addEventListener("alpine:init", () => {
18
19
  this.maxId = el.dataset.maxId || null;
19
20
  this.instance = el.dataset.instance || "";
20
21
  this.scope = el.dataset.scope || "local";
22
+ this.hashtag = el.dataset.hashtag || "";
21
23
 
22
24
  if (!this.maxId) {
23
25
  this.done = true;
@@ -53,6 +55,10 @@ document.addEventListener("alpine:init", () => {
53
55
  scope: this.scope,
54
56
  max_id: this.maxId,
55
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
+ }
56
62
 
57
63
  try {
58
64
  const res = await fetch(
@@ -0,0 +1,643 @@
1
+ /**
2
+ * Tab components — Alpine.js component for the tabbed explore page.
3
+ *
4
+ * Registers:
5
+ * apExploreTabs — tab management, timeline loading, infinite scroll
6
+ *
7
+ * Guard: init() exits early when .ap-explore-tabs-container is absent so
8
+ * this script is safe to load on all reader pages via the shared layout.
9
+ *
10
+ * Configuration is read from data-* attributes on the root element:
11
+ * data-mount-path — plugin mount path for API URL construction
12
+ * data-csrf — CSRF token from server session
13
+ */
14
+
15
+ document.addEventListener("alpine:init", () => {
16
+ // eslint-disable-next-line no-undef
17
+ Alpine.data("apExploreTabs", () => ({
18
+ // ── Tab list and active state ────────────────────────────────────────────
19
+ tabs: [],
20
+ activeTabId: null, // null = Search tab; string = user tab _id
21
+
22
+ // ── Tab management UI state ──────────────────────────────────────────────
23
+ pinning: false,
24
+ showHashtagForm: false,
25
+ hashtagInput: "",
26
+ error: null,
27
+
28
+ // ── Per-tab content state (keyed by tab _id) ─────────────────────────────
29
+ // Each entry: { loading, error, html, maxId, done, abortController }
30
+ // Hashtag tabs additionally carry: { cursors, sourceMeta }
31
+ // cursors: { [domain]: maxId|null } — per-instance pagination cursors
32
+ // sourceMeta: { instancesQueried, instancesTotal, instanceLabels }
33
+ tabState: {},
34
+
35
+ // ── Bounded content cache (last 5 tabs, LRU by access order) ────────────
36
+ _cacheOrder: [],
37
+
38
+ // ── Scroll observer for the active tab ───────────────────────────────────
39
+ _tabObserver: null,
40
+
41
+ // ── Configuration (read from data attributes) ────────────────────────────
42
+ _mountPath: "",
43
+ _csrfToken: "",
44
+ _reorderTimer: null,
45
+
46
+ // ── Lifecycle ────────────────────────────────────────────────────────────
47
+
48
+ init() {
49
+ if (!document.querySelector(".ap-explore-tabs-container")) return;
50
+ this._mountPath = this.$el.dataset.mountPath || "";
51
+ this._csrfToken = this.$el.dataset.csrf || "";
52
+ this._loadTabs();
53
+ },
54
+
55
+ destroy() {
56
+ if (this._tabObserver) {
57
+ this._tabObserver.disconnect();
58
+ this._tabObserver = null;
59
+ }
60
+ if (this._reorderTimer) {
61
+ clearTimeout(this._reorderTimer);
62
+ this._reorderTimer = null;
63
+ }
64
+ // Abort any in-flight requests
65
+ for (const state of Object.values(this.tabState)) {
66
+ if (state.abortController) state.abortController.abort();
67
+ }
68
+ },
69
+
70
+ async _loadTabs() {
71
+ try {
72
+ const res = await fetch(
73
+ `${this._mountPath}/admin/reader/api/tabs`,
74
+ { headers: { Accept: "application/json" } }
75
+ );
76
+ if (!res.ok) return;
77
+ const data = await res.json();
78
+ this.tabs = data.map((t) => ({ ...t, _id: String(t._id) }));
79
+ } catch {
80
+ // Non-critical — tab bar degrades gracefully to Search-only
81
+ }
82
+ },
83
+
84
+ // ── Tab content state helpers ─────────────────────────────────────────────
85
+
86
+ _getState(tabId) {
87
+ return this.tabState[tabId] || {
88
+ loading: false, error: null, html: "", maxId: null, done: false,
89
+ abortController: null,
90
+ };
91
+ },
92
+
93
+ _setState(tabId, update) {
94
+ const current = this._getState(tabId);
95
+ this.tabState = { ...this.tabState, [tabId]: { ...current, ...update } };
96
+ },
97
+
98
+ // LRU cache management — evict oldest when cache grows past 5 tabs
99
+ _touchCache(tabId) {
100
+ this._cacheOrder = this._cacheOrder.filter((id) => id !== tabId);
101
+ this._cacheOrder.push(tabId);
102
+
103
+ while (this._cacheOrder.length > 5) {
104
+ const evictId = this._cacheOrder.shift();
105
+ const evictedState = this.tabState[evictId];
106
+ if (evictedState) {
107
+ this._setState(evictId, {
108
+ html: "", maxId: null, done: false, loading: false,
109
+ });
110
+ }
111
+ }
112
+ },
113
+
114
+ // ── Tab switching ─────────────────────────────────────────────────────────
115
+
116
+ switchToSearch() {
117
+ this._abortActiveTabFetch();
118
+ this._teardownScrollObserver();
119
+ this.activeTabId = null;
120
+ this.error = null;
121
+ },
122
+
123
+ switchTab(tabId) {
124
+ if (this.activeTabId === tabId) return;
125
+ this._abortActiveTabFetch();
126
+ this._teardownScrollObserver();
127
+ this.activeTabId = tabId;
128
+ this.error = null;
129
+
130
+ const tab = this.tabs.find((t) => t._id === tabId);
131
+ if (!tab) return;
132
+
133
+ const state = this._getState(tabId);
134
+
135
+ if (tab.type === "instance") {
136
+ if (!state.html && !state.loading) {
137
+ // Cache miss — load first page
138
+ this.$nextTick(() => this._loadInstanceTab(tab));
139
+ } else if (state.html) {
140
+ // Cache hit — restore scroll observer
141
+ this._touchCache(tabId);
142
+ this.$nextTick(() => this._setupScrollObserver(tab));
143
+ }
144
+ } else if (tab.type === "hashtag") {
145
+ if (!state.html && !state.loading) {
146
+ this.$nextTick(() => this._loadHashtagTab(tab));
147
+ } else if (state.html) {
148
+ this._touchCache(tabId);
149
+ this.$nextTick(() => this._setupScrollObserver(tab));
150
+ }
151
+ }
152
+ },
153
+
154
+ _abortActiveTabFetch() {
155
+ if (!this.activeTabId) return;
156
+ const state = this._getState(this.activeTabId);
157
+ if (state.abortController) {
158
+ state.abortController.abort();
159
+ this._setState(this.activeTabId, {
160
+ abortController: null,
161
+ loading: false,
162
+ });
163
+ }
164
+ },
165
+
166
+ // ── Instance tab loading ──────────────────────────────────────────────────
167
+
168
+ async _loadInstanceTab(tab) {
169
+ const tabId = tab._id;
170
+ const abortController = new AbortController();
171
+ this._setState(tabId, {
172
+ loading: true, error: null, abortController,
173
+ });
174
+
175
+ try {
176
+ const url = new URL(
177
+ `${this._mountPath}/admin/reader/api/explore`,
178
+ window.location.origin
179
+ );
180
+ url.searchParams.set("instance", tab.domain);
181
+ url.searchParams.set("scope", tab.scope);
182
+
183
+ const res = await fetch(url.toString(), {
184
+ headers: { Accept: "application/json" },
185
+ signal: abortController.signal,
186
+ });
187
+
188
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
189
+
190
+ const data = await res.json();
191
+
192
+ this._setState(tabId, {
193
+ loading: false,
194
+ abortController: null,
195
+ html: data.html || "",
196
+ maxId: data.maxId || null,
197
+ done: !data.maxId,
198
+ error: null,
199
+ });
200
+
201
+ this._touchCache(tabId);
202
+
203
+ // Set up scroll observer after DOM updates
204
+ this.$nextTick(() => this._setupScrollObserver(tab));
205
+ } catch (err) {
206
+ if (err.name === "AbortError") return; // Tab was switched away — silent
207
+ this._setState(tabId, {
208
+ loading: false,
209
+ abortController: null,
210
+ error: err.message || "Could not load timeline",
211
+ });
212
+ }
213
+ },
214
+
215
+ async _loadMoreInstanceTab(tab) {
216
+ const tabId = tab._id;
217
+ const state = this._getState(tabId);
218
+ if (state.loading || state.done || !state.maxId) return;
219
+
220
+ const abortController = new AbortController();
221
+ this._setState(tabId, { loading: true, abortController });
222
+
223
+ try {
224
+ const url = new URL(
225
+ `${this._mountPath}/admin/reader/api/explore`,
226
+ window.location.origin
227
+ );
228
+ url.searchParams.set("instance", tab.domain);
229
+ url.searchParams.set("scope", tab.scope);
230
+ url.searchParams.set("max_id", state.maxId);
231
+
232
+ const res = await fetch(url.toString(), {
233
+ headers: { Accept: "application/json" },
234
+ signal: abortController.signal,
235
+ });
236
+
237
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
238
+
239
+ const data = await res.json();
240
+ const current = this._getState(tabId);
241
+
242
+ this._setState(tabId, {
243
+ loading: false,
244
+ abortController: null,
245
+ html: current.html + (data.html || ""),
246
+ maxId: data.maxId || null,
247
+ done: !data.maxId,
248
+ });
249
+ } catch (err) {
250
+ if (err.name === "AbortError") return;
251
+ this._setState(tabId, {
252
+ loading: false,
253
+ abortController: null,
254
+ error: err.message || "Could not load more posts",
255
+ });
256
+ }
257
+ },
258
+
259
+ // ── Hashtag tab loading ───────────────────────────────────────────────────
260
+
261
+ async _loadHashtagTab(tab) {
262
+ const tabId = tab._id;
263
+ const abortController = new AbortController();
264
+ this._setState(tabId, { loading: true, error: null, abortController });
265
+
266
+ try {
267
+ const url = new URL(
268
+ `${this._mountPath}/admin/reader/api/explore/hashtag`,
269
+ window.location.origin
270
+ );
271
+ url.searchParams.set("hashtag", tab.hashtag);
272
+ url.searchParams.set("cursors", "{}");
273
+
274
+ const res = await fetch(url.toString(), {
275
+ headers: { Accept: "application/json" },
276
+ signal: abortController.signal,
277
+ });
278
+
279
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
280
+
281
+ const data = await res.json();
282
+
283
+ this._setState(tabId, {
284
+ loading: false,
285
+ abortController: null,
286
+ html: data.html || "",
287
+ cursors: data.cursors || {},
288
+ sourceMeta: {
289
+ instancesQueried: data.instancesQueried || 0,
290
+ instancesTotal: data.instancesTotal || 0,
291
+ instanceLabels: data.instanceLabels || [],
292
+ },
293
+ done: !data.html || Object.values(data.cursors || {}).every((c) => !c),
294
+ error: null,
295
+ });
296
+
297
+ this._touchCache(tabId);
298
+ this.$nextTick(() => this._setupScrollObserver(tab));
299
+ } catch (err) {
300
+ if (err.name === "AbortError") return;
301
+ this._setState(tabId, {
302
+ loading: false,
303
+ abortController: null,
304
+ error: err.message || "Could not load hashtag timeline",
305
+ });
306
+ }
307
+ },
308
+
309
+ async _loadMoreHashtagTab(tab) {
310
+ const tabId = tab._id;
311
+ const state = this._getState(tabId);
312
+ if (state.loading || state.done) return;
313
+
314
+ const abortController = new AbortController();
315
+ this._setState(tabId, { loading: true, abortController });
316
+
317
+ try {
318
+ const url = new URL(
319
+ `${this._mountPath}/admin/reader/api/explore/hashtag`,
320
+ window.location.origin
321
+ );
322
+ url.searchParams.set("hashtag", tab.hashtag);
323
+ url.searchParams.set("cursors", JSON.stringify(state.cursors || {}));
324
+
325
+ const res = await fetch(url.toString(), {
326
+ headers: { Accept: "application/json" },
327
+ signal: abortController.signal,
328
+ });
329
+
330
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
331
+
332
+ const data = await res.json();
333
+ const current = this._getState(tabId);
334
+
335
+ const allCursorsExhausted = Object.values(data.cursors || {}).every(
336
+ (c) => !c
337
+ );
338
+
339
+ this._setState(tabId, {
340
+ loading: false,
341
+ abortController: null,
342
+ html: current.html + (data.html || ""),
343
+ cursors: data.cursors || {},
344
+ done: !data.html || allCursorsExhausted,
345
+ });
346
+ } catch (err) {
347
+ if (err.name === "AbortError") return;
348
+ this._setState(tabId, {
349
+ loading: false,
350
+ abortController: null,
351
+ error: err.message || "Could not load more posts",
352
+ });
353
+ }
354
+ },
355
+
356
+ async retryTab(tab) {
357
+ const tabId = tab._id;
358
+ this._setState(tabId, {
359
+ error: null, html: "", maxId: null, done: false,
360
+ cursors: {}, sourceMeta: null,
361
+ });
362
+ if (tab.type === "instance") {
363
+ await this._loadInstanceTab(tab);
364
+ } else if (tab.type === "hashtag") {
365
+ await this._loadHashtagTab(tab);
366
+ }
367
+ },
368
+
369
+ // ── Infinite scroll for tab panels ───────────────────────────────────────
370
+
371
+ _setupScrollObserver(tab) {
372
+ this._teardownScrollObserver();
373
+
374
+ const panel = this.$el.querySelector(`#ap-tab-panel-${tab._id}`);
375
+ if (!panel) return;
376
+
377
+ const sentinel = panel.querySelector(".ap-tab-sentinel");
378
+ if (!sentinel) return;
379
+
380
+ this._tabObserver = new IntersectionObserver(
381
+ (entries) => {
382
+ for (const entry of entries) {
383
+ if (entry.isIntersecting) {
384
+ const state = this._getState(tab._id);
385
+ if (!state.loading && !state.done) {
386
+ if (tab.type === "instance" && state.maxId) {
387
+ this._loadMoreInstanceTab(tab);
388
+ } else if (tab.type === "hashtag") {
389
+ this._loadMoreHashtagTab(tab);
390
+ }
391
+ }
392
+ }
393
+ }
394
+ },
395
+ { rootMargin: "200px" }
396
+ );
397
+ this._tabObserver.observe(sentinel);
398
+ },
399
+
400
+ _teardownScrollObserver() {
401
+ if (this._tabObserver) {
402
+ this._tabObserver.disconnect();
403
+ this._tabObserver = null;
404
+ }
405
+ },
406
+
407
+ // ── Tab label helpers ─────────────────────────────────────────────────────
408
+
409
+ tabLabel(tab) {
410
+ return tab.type === "instance" ? tab.domain : `#${tab.hashtag}`;
411
+ },
412
+
413
+ hashtagSourcesLine(tab) {
414
+ const state = this._getState(tab._id);
415
+ const meta = state.sourceMeta;
416
+ if (!meta || !meta.instancesQueried) return "";
417
+ const n = meta.instancesQueried;
418
+ const total = meta.instancesTotal;
419
+ const labels = meta.instanceLabels || [];
420
+ const tag = tab.hashtag || "";
421
+ const suffix = n === 1 ? "instance" : "instances";
422
+ let line = `Searching #${tag} across ${n} ${suffix}`;
423
+ if (n < total) {
424
+ line += ` (${n} of ${total} pinned)`;
425
+ }
426
+ if (labels.length > 0) {
427
+ line += `: ${labels.join(", ")}`;
428
+ }
429
+ return line;
430
+ },
431
+
432
+ // ── Keyboard navigation (WAI-ARIA Tabs pattern) ───────────────────────────
433
+
434
+ handleTabKeydown(event, currentIndex) {
435
+ const total = this.tabs.length + 1;
436
+ let nextIndex = null;
437
+
438
+ if (event.key === "ArrowRight") {
439
+ event.preventDefault();
440
+ nextIndex = (currentIndex + 1) % total;
441
+ } else if (event.key === "ArrowLeft") {
442
+ event.preventDefault();
443
+ nextIndex = (currentIndex - 1 + total) % total;
444
+ } else if (event.key === "Home") {
445
+ event.preventDefault();
446
+ nextIndex = 0;
447
+ } else if (event.key === "End") {
448
+ event.preventDefault();
449
+ nextIndex = total - 1;
450
+ }
451
+
452
+ if (nextIndex !== null) {
453
+ const tabEls = this.$el.querySelectorAll('[role="tab"]');
454
+ if (tabEls[nextIndex]) tabEls[nextIndex].focus();
455
+ }
456
+ },
457
+
458
+ // ── Pin current search result as instance tab ─────────────────────────────
459
+
460
+ async pinInstance(domain, scope) {
461
+ if (this.pinning) return;
462
+ this.pinning = true;
463
+ this.error = null;
464
+
465
+ try {
466
+ const res = await fetch(
467
+ `${this._mountPath}/admin/reader/api/tabs`,
468
+ {
469
+ method: "POST",
470
+ headers: {
471
+ "Content-Type": "application/json",
472
+ "X-CSRF-Token": this._csrfToken,
473
+ },
474
+ body: JSON.stringify({ type: "instance", domain, scope }),
475
+ }
476
+ );
477
+
478
+ if (res.status === 409) {
479
+ const existing = this.tabs.find(
480
+ (t) => t.type === "instance" && t.domain === domain && t.scope === scope
481
+ );
482
+ if (existing) this.switchTab(existing._id);
483
+ return;
484
+ }
485
+
486
+ if (res.status === 403) {
487
+ this.error = "Session expired — please refresh the page.";
488
+ return;
489
+ }
490
+
491
+ if (!res.ok) return;
492
+
493
+ const newTab = await res.json();
494
+ newTab._id = String(newTab._id);
495
+ this.tabs.push(newTab);
496
+ this.switchTab(newTab._id);
497
+ } catch {
498
+ // Network error — silent
499
+ } finally {
500
+ this.pinning = false;
501
+ }
502
+ },
503
+
504
+ // ── Add hashtag tab ───────────────────────────────────────────────────────
505
+
506
+ async submitHashtagTab() {
507
+ const hashtag = (this.hashtagInput || "").replace(/^#+/, "").trim();
508
+ if (!hashtag) return;
509
+
510
+ try {
511
+ const res = await fetch(
512
+ `${this._mountPath}/admin/reader/api/tabs`,
513
+ {
514
+ method: "POST",
515
+ headers: {
516
+ "Content-Type": "application/json",
517
+ "X-CSRF-Token": this._csrfToken,
518
+ },
519
+ body: JSON.stringify({ type: "hashtag", hashtag }),
520
+ }
521
+ );
522
+
523
+ if (res.status === 409) {
524
+ const existing = this.tabs.find(
525
+ (t) => t.type === "hashtag" && t.hashtag === hashtag
526
+ );
527
+ if (existing) {
528
+ this.switchTab(existing._id);
529
+ this.showHashtagForm = false;
530
+ this.hashtagInput = "";
531
+ }
532
+ return;
533
+ }
534
+
535
+ if (res.status === 403) {
536
+ this.error = "Session expired — please refresh the page.";
537
+ return;
538
+ }
539
+
540
+ if (!res.ok) return;
541
+
542
+ const newTab = await res.json();
543
+ newTab._id = String(newTab._id);
544
+ this.tabs.push(newTab);
545
+ this.hashtagInput = "";
546
+ this.showHashtagForm = false;
547
+ this.switchTab(newTab._id);
548
+ } catch {
549
+ // Network error — silent
550
+ }
551
+ },
552
+
553
+ // ── Remove a tab ──────────────────────────────────────────────────────────
554
+
555
+ async removeTab(tab) {
556
+ const body =
557
+ tab.type === "instance"
558
+ ? { type: "instance", domain: tab.domain, scope: tab.scope }
559
+ : { type: "hashtag", hashtag: tab.hashtag };
560
+
561
+ try {
562
+ const res = await fetch(
563
+ `${this._mountPath}/admin/reader/api/tabs/remove`,
564
+ {
565
+ method: "POST",
566
+ headers: {
567
+ "Content-Type": "application/json",
568
+ "X-CSRF-Token": this._csrfToken,
569
+ },
570
+ body: JSON.stringify(body),
571
+ }
572
+ );
573
+
574
+ if (res.status === 403) {
575
+ this.error = "Session expired — please refresh the page.";
576
+ return;
577
+ }
578
+
579
+ if (!res.ok) return;
580
+
581
+ // Clean up tab state
582
+ const { [tab._id]: _removed, ...remaining } = this.tabState;
583
+ this.tabState = remaining;
584
+ this._cacheOrder = this._cacheOrder.filter((id) => id !== tab._id);
585
+
586
+ this.tabs = this.tabs
587
+ .filter((t) => t._id !== tab._id)
588
+ .map((t, i) => ({ ...t, order: i }));
589
+
590
+ if (this.activeTabId === tab._id) {
591
+ this._teardownScrollObserver();
592
+ this.activeTabId = null;
593
+ }
594
+ } catch {
595
+ // Network error — silent
596
+ }
597
+ },
598
+
599
+ // ── Tab reordering ────────────────────────────────────────────────────────
600
+
601
+ moveUp(tab) {
602
+ const idx = this.tabs.findIndex((t) => t._id === tab._id);
603
+ if (idx <= 0) return;
604
+ const copy = [...this.tabs];
605
+ [copy[idx - 1], copy[idx]] = [copy[idx], copy[idx - 1]];
606
+ this.tabs = copy.map((t, i) => ({ ...t, order: i }));
607
+ this._scheduleReorder();
608
+ },
609
+
610
+ moveDown(tab) {
611
+ const idx = this.tabs.findIndex((t) => t._id === tab._id);
612
+ if (idx < 0 || idx >= this.tabs.length - 1) return;
613
+ const copy = [...this.tabs];
614
+ [copy[idx], copy[idx + 1]] = [copy[idx + 1], copy[idx]];
615
+ this.tabs = copy.map((t, i) => ({ ...t, order: i }));
616
+ this._scheduleReorder();
617
+ },
618
+
619
+ _scheduleReorder() {
620
+ if (this._reorderTimer) clearTimeout(this._reorderTimer);
621
+ this._reorderTimer = setTimeout(() => this._sendReorder(), 500);
622
+ },
623
+
624
+ async _sendReorder() {
625
+ try {
626
+ const tabIds = this.tabs.map((t) => t._id);
627
+ await fetch(
628
+ `${this._mountPath}/admin/reader/api/tabs/reorder`,
629
+ {
630
+ method: "PATCH",
631
+ headers: {
632
+ "Content-Type": "application/json",
633
+ "X-CSRF-Token": this._csrfToken,
634
+ },
635
+ body: JSON.stringify({ tabIds }),
636
+ }
637
+ );
638
+ } catch {
639
+ // Non-critical — reorder failure doesn't affect UX
640
+ }
641
+ },
642
+ }));
643
+ });