mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
feat: enhance ActivityPub reader with mentions, hashtags, infinite scroll, explore, and tag following
- Fix mentions/hashtags bug: separate Fedify Mention and Hashtag types into distinct mentions[] and category[] arrays with proper @ and # rendering - Add hashtag timeline filtering at /admin/reader/tag with regex-safe queries - Replace prev/next pagination with AlpineJS infinite scroll (IntersectionObserver) with no-JS fallback pagination preserved - Add public instance timeline explorer at /admin/reader/explore with SSRF prevention and XSS sanitization via Mastodon-compatible API - Add hashtag following with ap_followed_tags collection, inbox listener integration for non-followed accounts, and followed tags sidebar display - Include one-time migration script for legacy timeline data
This commit is contained in:
183
assets/reader-infinite-scroll.js
Normal file
183
assets/reader-infinite-scroll.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Infinite scroll — AlpineJS component for AJAX load-more on the timeline
|
||||
* Registers the `apInfiniteScroll` Alpine data component.
|
||||
*/
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
// eslint-disable-next-line no-undef
|
||||
Alpine.data("apExploreScroll", () => ({
|
||||
loading: false,
|
||||
done: false,
|
||||
maxId: null,
|
||||
instance: "",
|
||||
scope: "local",
|
||||
observer: null,
|
||||
|
||||
init() {
|
||||
const el = this.$el;
|
||||
this.maxId = el.dataset.maxId || null;
|
||||
this.instance = el.dataset.instance || "";
|
||||
this.scope = el.dataset.scope || "local";
|
||||
|
||||
if (!this.maxId) {
|
||||
this.done = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting && !this.loading && !this.done) {
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
|
||||
if (this.$refs.sentinel) {
|
||||
this.observer.observe(this.$refs.sentinel);
|
||||
}
|
||||
},
|
||||
|
||||
async loadMore() {
|
||||
if (this.loading || this.done || !this.maxId) return;
|
||||
|
||||
this.loading = true;
|
||||
|
||||
const timeline = document.getElementById("ap-explore-timeline");
|
||||
const mountPath = timeline ? timeline.dataset.mountPath : "";
|
||||
|
||||
const params = new URLSearchParams({
|
||||
instance: this.instance,
|
||||
scope: this.scope,
|
||||
max_id: this.maxId,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${mountPath}/admin/reader/api/explore?${params.toString()}`,
|
||||
{ headers: { Accept: "application/json" } }
|
||||
);
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.html && timeline) {
|
||||
timeline.insertAdjacentHTML("beforeend", data.html);
|
||||
}
|
||||
|
||||
if (data.maxId) {
|
||||
this.maxId = data.maxId;
|
||||
} else {
|
||||
this.done = true;
|
||||
if (this.observer) this.observer.disconnect();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[ap-explore-scroll] load failed:", err.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.observer) this.observer.disconnect();
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
Alpine.data("apInfiniteScroll", () => ({
|
||||
loading: false,
|
||||
done: false,
|
||||
before: null,
|
||||
tab: "",
|
||||
tag: "",
|
||||
observer: null,
|
||||
|
||||
init() {
|
||||
const el = this.$el;
|
||||
this.before = el.dataset.before || null;
|
||||
this.tab = el.dataset.tab || "";
|
||||
this.tag = el.dataset.tag || "";
|
||||
|
||||
// Hide the no-JS pagination fallback now that JS is active
|
||||
const paginationEl =
|
||||
document.getElementById("ap-reader-pagination") ||
|
||||
document.getElementById("ap-tag-pagination");
|
||||
if (paginationEl) {
|
||||
paginationEl.style.display = "none";
|
||||
}
|
||||
|
||||
if (!this.before) {
|
||||
this.done = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up IntersectionObserver to auto-load when sentinel comes into view
|
||||
this.observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting && !this.loading && !this.done) {
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
|
||||
if (this.$refs.sentinel) {
|
||||
this.observer.observe(this.$refs.sentinel);
|
||||
}
|
||||
},
|
||||
|
||||
async loadMore() {
|
||||
if (this.loading || this.done || !this.before) return;
|
||||
|
||||
this.loading = true;
|
||||
|
||||
const timeline = document.getElementById("ap-timeline");
|
||||
const mountPath = timeline ? timeline.dataset.mountPath : "";
|
||||
|
||||
const params = new URLSearchParams({ before: this.before });
|
||||
if (this.tab) params.set("tab", this.tab);
|
||||
if (this.tag) params.set("tag", this.tag);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${mountPath}/admin/reader/api/timeline?${params.toString()}`,
|
||||
{ headers: { Accept: "application/json" } }
|
||||
);
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.html && timeline) {
|
||||
// Append the returned pre-rendered HTML
|
||||
timeline.insertAdjacentHTML("beforeend", data.html);
|
||||
}
|
||||
|
||||
if (data.before) {
|
||||
this.before = data.before;
|
||||
} else {
|
||||
// No more items
|
||||
this.done = true;
|
||||
if (this.observer) this.observer.disconnect();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[ap-infinite-scroll] load failed:", err.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
appendItems(/* detail */) {
|
||||
// Custom event hook — not used in this implementation but kept for extensibility
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.observer) this.observer.disconnect();
|
||||
},
|
||||
}));
|
||||
});
|
||||
@@ -655,6 +655,25 @@
|
||||
color: var(--color-on-background);
|
||||
}
|
||||
|
||||
.ap-card__mention {
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
border-radius: var(--border-radius-large);
|
||||
color: var(--color-accent);
|
||||
font-size: var(--font-size-s);
|
||||
padding: 2px var(--space-xs);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ap-card__mention:hover {
|
||||
background: color-mix(in srgb, var(--color-accent) 22%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.ap-card__mention--legacy {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Interaction Buttons
|
||||
========================================================================== */
|
||||
@@ -735,6 +754,55 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Hidden once Alpine is active (JS replaces with infinite scroll) */
|
||||
.ap-pagination--js-hidden {
|
||||
/* Shown by default for no-JS fallback — Alpine hides via display:none */
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Infinite Scroll / Load More
|
||||
========================================================================== */
|
||||
|
||||
.ap-load-more {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-s);
|
||||
padding: var(--space-m) 0;
|
||||
}
|
||||
|
||||
.ap-load-more__sentinel {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ap-load-more__btn {
|
||||
background: var(--color-offset);
|
||||
border: var(--border-width-thin) solid var(--color-outline);
|
||||
border-radius: var(--border-radius-small);
|
||||
color: var(--color-on-background);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-s);
|
||||
padding: var(--space-xs) var(--space-m);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.ap-load-more__btn:hover:not(:disabled) {
|
||||
background: var(--color-offset-variant);
|
||||
}
|
||||
|
||||
.ap-load-more__btn:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ap-load-more__done {
|
||||
color: var(--color-on-offset);
|
||||
font-size: var(--font-size-s);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Compose Form
|
||||
========================================================================== */
|
||||
@@ -1572,6 +1640,204 @@
|
||||
box-shadow: 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Tag Timeline Header
|
||||
========================================================================== */
|
||||
|
||||
.ap-tag-header {
|
||||
align-items: flex-start;
|
||||
background: var(--color-offset);
|
||||
border-bottom: var(--border-width-thin) solid var(--color-outline);
|
||||
border-radius: var(--border-radius-small);
|
||||
display: flex;
|
||||
gap: var(--space-m);
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-m);
|
||||
padding: var(--space-m);
|
||||
}
|
||||
|
||||
.ap-tag-header__title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin: 0 0 var(--space-xs);
|
||||
}
|
||||
|
||||
.ap-tag-header__count {
|
||||
color: var(--color-on-offset);
|
||||
font-size: var(--font-size-s);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ap-tag-header__actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
.ap-tag-header__follow-btn {
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-small);
|
||||
color: var(--color-on-accent);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-s);
|
||||
padding: var(--space-xs) var(--space-s);
|
||||
}
|
||||
|
||||
.ap-tag-header__follow-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.ap-tag-header__unfollow-btn {
|
||||
background: transparent;
|
||||
border: var(--border-width-thin) solid var(--color-outline);
|
||||
border-radius: var(--border-radius-small);
|
||||
color: var(--color-on-background);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-s);
|
||||
padding: var(--space-xs) var(--space-s);
|
||||
}
|
||||
|
||||
.ap-tag-header__unfollow-btn:hover {
|
||||
border-color: var(--color-on-background);
|
||||
}
|
||||
|
||||
.ap-tag-header__back {
|
||||
color: var(--color-on-offset);
|
||||
font-size: var(--font-size-s);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ap-tag-header__back:hover {
|
||||
color: var(--color-on-background);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ap-tag-header {
|
||||
flex-direction: column;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
.ap-tag-header__actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Reader Tools Bar (Explore link, etc.)
|
||||
========================================================================== */
|
||||
|
||||
.ap-reader-tools {
|
||||
display: flex;
|
||||
gap: var(--space-s);
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--space-s);
|
||||
}
|
||||
|
||||
.ap-reader-tools__explore {
|
||||
color: var(--color-on-offset);
|
||||
font-size: var(--font-size-s);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ap-reader-tools__explore:hover {
|
||||
color: var(--color-on-background);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Explore Page
|
||||
========================================================================== */
|
||||
|
||||
.ap-explore-header {
|
||||
margin-bottom: var(--space-m);
|
||||
}
|
||||
|
||||
.ap-explore-header__title {
|
||||
font-size: var(--font-size-xl);
|
||||
margin: 0 0 var(--space-xs);
|
||||
}
|
||||
|
||||
.ap-explore-header__desc {
|
||||
color: var(--color-on-offset);
|
||||
font-size: var(--font-size-s);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ap-explore-form {
|
||||
background: var(--color-offset);
|
||||
border: var(--border-width-thin) solid var(--color-outline);
|
||||
border-radius: var(--border-radius-small);
|
||||
margin-bottom: var(--space-m);
|
||||
padding: var(--space-m);
|
||||
}
|
||||
|
||||
.ap-explore-form__row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-s);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ap-explore-form__input {
|
||||
border: var(--border-width-thin) solid var(--color-outline);
|
||||
border-radius: var(--border-radius-small);
|
||||
flex: 1;
|
||||
font-size: var(--font-size-base);
|
||||
min-width: 0;
|
||||
padding: var(--space-xs) var(--space-s);
|
||||
}
|
||||
|
||||
.ap-explore-form__scope {
|
||||
display: flex;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
.ap-explore-form__scope-label {
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: var(--font-size-s);
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.ap-explore-form__btn {
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-small);
|
||||
color: var(--color-on-primary);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-s);
|
||||
padding: var(--space-xs) var(--space-m);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ap-explore-form__btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.ap-explore-error {
|
||||
background: color-mix(in srgb, var(--color-red45) 10%, transparent);
|
||||
border: var(--border-width-thin) solid var(--color-red45);
|
||||
border-radius: var(--border-radius-small);
|
||||
color: var(--color-red45);
|
||||
margin-bottom: var(--space-m);
|
||||
padding: var(--space-s) var(--space-m);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ap-explore-form__row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ap-explore-form__btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Replies — indented from the other side */
|
||||
.ap-post-detail__replies {
|
||||
margin-left: var(--space-l);
|
||||
@@ -1582,3 +1848,19 @@
|
||||
padding-left: var(--space-m);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Followed tags bar */
|
||||
.ap-followed-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) 0;
|
||||
margin-bottom: var(--space-s);
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
|
||||
.ap-followed-tags__label {
|
||||
color: var(--color-on-offset);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
34
index.js
34
index.js
@@ -59,6 +59,10 @@ import {
|
||||
featuredTagsRemoveController,
|
||||
} from "./lib/controllers/featured-tags.js";
|
||||
import { resolveController } from "./lib/controllers/resolve.js";
|
||||
import { tagTimelineController } from "./lib/controllers/tag-timeline.js";
|
||||
import { apiTimelineController } from "./lib/controllers/api-timeline.js";
|
||||
import { exploreController, exploreApiController } from "./lib/controllers/explore.js";
|
||||
import { followTagController, unfollowTagController } from "./lib/controllers/follow-tag.js";
|
||||
import { publicProfileController } from "./lib/controllers/public-profile.js";
|
||||
import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.js";
|
||||
import { myProfileController } from "./lib/controllers/my-profile.js";
|
||||
@@ -71,6 +75,7 @@ import {
|
||||
import { startBatchRefollow } from "./lib/batch-refollow.js";
|
||||
import { logActivity } from "./lib/activity-log.js";
|
||||
import { scheduleCleanup } from "./lib/timeline-cleanup.js";
|
||||
import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
|
||||
|
||||
const defaults = {
|
||||
mountPath: "/activitypub",
|
||||
@@ -218,6 +223,12 @@ export default class ActivityPubEndpoint {
|
||||
|
||||
router.get("/", dashboardController(mp));
|
||||
router.get("/admin/reader", readerController(mp));
|
||||
router.get("/admin/reader/tag", tagTimelineController(mp));
|
||||
router.get("/admin/reader/api/timeline", apiTimelineController(mp));
|
||||
router.get("/admin/reader/explore", exploreController(mp));
|
||||
router.get("/admin/reader/api/explore", exploreApiController(mp));
|
||||
router.post("/admin/reader/follow-tag", followTagController(mp));
|
||||
router.post("/admin/reader/unfollow-tag", unfollowTagController(mp));
|
||||
router.get("/admin/reader/notifications", notificationsController(mp));
|
||||
router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp));
|
||||
router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp));
|
||||
@@ -855,6 +866,7 @@ export default class ActivityPubEndpoint {
|
||||
Indiekit.addCollection("ap_blocked");
|
||||
Indiekit.addCollection("ap_interactions");
|
||||
Indiekit.addCollection("ap_notes");
|
||||
Indiekit.addCollection("ap_followed_tags");
|
||||
|
||||
// Store collection references (posts resolved lazily)
|
||||
const indiekitCollections = Indiekit.collections;
|
||||
@@ -874,6 +886,7 @@ export default class ActivityPubEndpoint {
|
||||
ap_blocked: indiekitCollections.get("ap_blocked"),
|
||||
ap_interactions: indiekitCollections.get("ap_interactions"),
|
||||
ap_notes: indiekitCollections.get("ap_notes"),
|
||||
ap_followed_tags: indiekitCollections.get("ap_followed_tags"),
|
||||
get posts() {
|
||||
return indiekitCollections.get("posts");
|
||||
},
|
||||
@@ -985,6 +998,18 @@ export default class ActivityPubEndpoint {
|
||||
{ type: 1 },
|
||||
{ background: true },
|
||||
);
|
||||
|
||||
// Followed hashtags — unique on tag (case-insensitive via normalization at write time)
|
||||
this._collections.ap_followed_tags.createIndex(
|
||||
{ tag: 1 },
|
||||
{ unique: true, background: true },
|
||||
);
|
||||
|
||||
// Tag filtering index on timeline
|
||||
this._collections.ap_timeline.createIndex(
|
||||
{ category: 1, published: -1 },
|
||||
{ background: true },
|
||||
);
|
||||
} catch {
|
||||
// Index creation failed — collections not yet available.
|
||||
// Indexes already exist from previous startups; non-fatal.
|
||||
@@ -1039,6 +1064,15 @@ export default class ActivityPubEndpoint {
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
// Run one-time migrations (idempotent — safe to run on every startup)
|
||||
runSeparateMentionsMigration(this._collections).then(({ skipped, updated }) => {
|
||||
if (!skipped) {
|
||||
console.log(`[ActivityPub] Migration separate-mentions: updated ${updated} timeline items`);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error("[ActivityPub] Migration separate-mentions failed:", error.message);
|
||||
});
|
||||
|
||||
// Schedule timeline retention cleanup (runs on startup + every 24h)
|
||||
if (this.options.timelineRetention > 0) {
|
||||
scheduleCleanup(this._collections, this.options.timelineRetention);
|
||||
|
||||
170
lib/controllers/api-timeline.js
Normal file
170
lib/controllers/api-timeline.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* JSON API timeline endpoint — returns pre-rendered HTML cards for infinite scroll AJAX loads.
|
||||
*/
|
||||
|
||||
import { getTimelineItems } from "../storage/timeline.js";
|
||||
import { getToken } from "../csrf.js";
|
||||
import {
|
||||
getMutedUrls,
|
||||
getMutedKeywords,
|
||||
getBlockedUrls,
|
||||
getFilterMode,
|
||||
} from "../storage/moderation.js";
|
||||
|
||||
export function apiTimelineController(mountPath) {
|
||||
return async (request, response, next) => {
|
||||
try {
|
||||
const { application } = request.app.locals;
|
||||
const collections = {
|
||||
ap_timeline: application?.collections?.get("ap_timeline"),
|
||||
};
|
||||
|
||||
// Query parameters
|
||||
const tab = request.query.tab || "notes";
|
||||
const tag = typeof request.query.tag === "string" ? request.query.tag.trim() : "";
|
||||
const before = request.query.before;
|
||||
const limit = 20;
|
||||
|
||||
// Build storage query options (same logic as readerController)
|
||||
const options = { before, limit };
|
||||
|
||||
if (tag) {
|
||||
options.tag = tag;
|
||||
} else {
|
||||
if (tab === "notes") {
|
||||
options.type = "note";
|
||||
options.excludeReplies = true;
|
||||
} else if (tab === "articles") {
|
||||
options.type = "article";
|
||||
} else if (tab === "boosts") {
|
||||
options.type = "boost";
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getTimelineItems(collections, options);
|
||||
|
||||
// Client-side tab filtering for types not supported by storage
|
||||
let items = result.items;
|
||||
if (!tag) {
|
||||
if (tab === "replies") {
|
||||
items = items.filter((item) => item.inReplyTo);
|
||||
} else if (tab === "media") {
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
(item.photo && item.photo.length > 0) ||
|
||||
(item.video && item.video.length > 0) ||
|
||||
(item.audio && item.audio.length > 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply moderation filters
|
||||
const modCollections = {
|
||||
ap_muted: application?.collections?.get("ap_muted"),
|
||||
ap_blocked: application?.collections?.get("ap_blocked"),
|
||||
ap_profile: application?.collections?.get("ap_profile"),
|
||||
};
|
||||
const [mutedUrls, mutedKeywords, blockedUrls, filterMode] =
|
||||
await Promise.all([
|
||||
getMutedUrls(modCollections),
|
||||
getMutedKeywords(modCollections),
|
||||
getBlockedUrls(modCollections),
|
||||
getFilterMode(modCollections),
|
||||
]);
|
||||
const blockedSet = new Set(blockedUrls);
|
||||
const mutedSet = new Set(mutedUrls);
|
||||
|
||||
if (blockedSet.size > 0 || mutedSet.size > 0 || mutedKeywords.length > 0) {
|
||||
items = items.filter((item) => {
|
||||
if (item.author?.url && blockedSet.has(item.author.url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isMutedActor = item.author?.url && mutedSet.has(item.author.url);
|
||||
let matchedKeyword = null;
|
||||
if (mutedKeywords.length > 0) {
|
||||
const searchable = [item.content?.text, item.name, item.summary]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
if (searchable) {
|
||||
matchedKeyword = mutedKeywords.find((kw) =>
|
||||
searchable.includes(kw.toLowerCase())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMutedActor || matchedKeyword) {
|
||||
if (filterMode === "warn") {
|
||||
item._moderated = true;
|
||||
item._moderationReason = isMutedActor ? "muted_account" : "muted_keyword";
|
||||
if (matchedKeyword) item._moderationKeyword = matchedKeyword;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Get interaction state
|
||||
const interactionsCol = application?.collections?.get("ap_interactions");
|
||||
const interactionMap = {};
|
||||
|
||||
if (interactionsCol) {
|
||||
const lookupUrls = new Set();
|
||||
const objectUrlToUid = new Map();
|
||||
for (const item of items) {
|
||||
const uid = item.uid;
|
||||
const displayUrl = item.url || item.originalUrl;
|
||||
if (uid) { lookupUrls.add(uid); objectUrlToUid.set(uid, uid); }
|
||||
if (displayUrl) { lookupUrls.add(displayUrl); objectUrlToUid.set(displayUrl, uid || displayUrl); }
|
||||
}
|
||||
if (lookupUrls.size > 0) {
|
||||
const interactions = await interactionsCol
|
||||
.find({ objectUrl: { $in: [...lookupUrls] } })
|
||||
.toArray();
|
||||
for (const interaction of interactions) {
|
||||
const key = objectUrlToUid.get(interaction.objectUrl) || interaction.objectUrl;
|
||||
if (!interactionMap[key]) interactionMap[key] = {};
|
||||
interactionMap[key][interaction.type] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const csrfToken = getToken(request.session);
|
||||
|
||||
// Render each card server-side using the same Nunjucks template
|
||||
// Merge response.locals so that i18n (__), mountPath, etc. are available
|
||||
const templateData = {
|
||||
...response.locals,
|
||||
mountPath,
|
||||
csrfToken,
|
||||
interactionMap,
|
||||
};
|
||||
|
||||
const htmlParts = await Promise.all(
|
||||
items.map((item) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.app.render(
|
||||
"partials/ap-item-card.njk",
|
||||
{ ...templateData, item },
|
||||
(err, html) => {
|
||||
if (err) reject(err);
|
||||
else resolve(html);
|
||||
}
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
response.json({
|
||||
html: htmlParts.join(""),
|
||||
before: result.before,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
293
lib/controllers/explore.js
Normal file
293
lib/controllers/explore.js
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Explore controller — browse public timelines from remote Mastodon-compatible instances.
|
||||
*
|
||||
* All remote API calls are server-side (no CORS issues).
|
||||
* Remote HTML is always passed through sanitizeContent() before storage.
|
||||
*/
|
||||
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import { sanitizeContent } from "../timeline-store.js";
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const MAX_RESULTS = 20;
|
||||
|
||||
/**
|
||||
* Validate the instance parameter to prevent SSRF.
|
||||
* Only allows hostnames — no IPs, no localhost, no port numbers for exotic attacks.
|
||||
* @param {string} instance - Raw instance parameter from query string
|
||||
* @returns {string|null} Validated hostname or null
|
||||
*/
|
||||
function validateInstance(instance) {
|
||||
if (!instance || typeof instance !== "string") return null;
|
||||
|
||||
try {
|
||||
// Prepend https:// to parse as URL
|
||||
const url = new URL(`https://${instance.trim()}`);
|
||||
|
||||
// Must be a plain hostname — no IP addresses, no localhost
|
||||
const hostname = url.hostname;
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "0.0.0.0" ||
|
||||
hostname === "::1" ||
|
||||
hostname.startsWith("192.168.") ||
|
||||
hostname.startsWith("10.") ||
|
||||
hostname.startsWith("169.254.") ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
|
||||
/^[0-9]{1,3}(\.[0-9]{1,3}){3}$/.test(hostname) || // IPv4
|
||||
hostname.includes("[") // IPv6
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only allow the hostname (no path, no port override)
|
||||
return hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Mastodon API status object to our timeline item format.
|
||||
* @param {object} status - Mastodon API status
|
||||
* @param {string} instance - Instance hostname (for handle construction)
|
||||
* @returns {object} Timeline item compatible with ap-item-card.njk
|
||||
*/
|
||||
function mapMastodonStatusToItem(status, instance) {
|
||||
const account = status.account || {};
|
||||
const acct = account.acct || "";
|
||||
// Mastodon acct is "user" for local, "user@remote" for remote
|
||||
const handle = acct.includes("@") ? `@${acct}` : `@${acct}@${instance}`;
|
||||
|
||||
// Map mentions — store without leading @ (template prepends it)
|
||||
const mentions = (status.mentions || []).map((m) => ({
|
||||
name: m.acct.includes("@") ? m.acct : `${m.acct}@${instance}`,
|
||||
url: m.url || "",
|
||||
}));
|
||||
|
||||
// Map hashtags
|
||||
const category = (status.tags || []).map((t) => t.name || "");
|
||||
|
||||
// Map media attachments
|
||||
const photo = [];
|
||||
const video = [];
|
||||
const audio = [];
|
||||
for (const att of status.media_attachments || []) {
|
||||
const url = att.url || att.remote_url || "";
|
||||
if (!url) continue;
|
||||
if (att.type === "image" || att.type === "gifv") {
|
||||
photo.push(url);
|
||||
} else if (att.type === "video") {
|
||||
video.push(url);
|
||||
} else if (att.type === "audio") {
|
||||
audio.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uid: status.url || status.uri || "",
|
||||
url: status.url || status.uri || "",
|
||||
type: "note",
|
||||
name: "",
|
||||
content: {
|
||||
text: (status.content || "").replace(/<[^>]*>/g, ""),
|
||||
html: sanitizeContent(status.content || ""),
|
||||
},
|
||||
summary: status.spoiler_text || "",
|
||||
sensitive: status.sensitive || false,
|
||||
published: status.created_at || new Date().toISOString(),
|
||||
author: {
|
||||
name: sanitizeHtml(account.display_name || account.username || "Unknown", { allowedTags: [], allowedAttributes: {} }),
|
||||
url: account.url || "",
|
||||
photo: account.avatar || account.avatar_static || "",
|
||||
handle,
|
||||
},
|
||||
category,
|
||||
mentions,
|
||||
photo,
|
||||
video,
|
||||
audio,
|
||||
inReplyTo: status.in_reply_to_id ? `https://${instance}/web/statuses/${status.in_reply_to_id}` : "",
|
||||
createdAt: new Date().toISOString(),
|
||||
// Explore-specific: track source instance
|
||||
_explore: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function exploreController(mountPath) {
|
||||
return async (request, response, next) => {
|
||||
try {
|
||||
const rawInstance = request.query.instance || "";
|
||||
const scope = request.query.scope === "federated" ? "federated" : "local";
|
||||
const maxId = request.query.max_id || "";
|
||||
|
||||
// No instance specified — render clean initial page (no error)
|
||||
if (!rawInstance.trim()) {
|
||||
return response.render("activitypub-explore", {
|
||||
title: response.locals.__("activitypub.reader.explore.title"),
|
||||
instance: "",
|
||||
scope,
|
||||
items: [],
|
||||
maxId: null,
|
||||
error: null,
|
||||
mountPath,
|
||||
});
|
||||
}
|
||||
|
||||
const instance = validateInstance(rawInstance);
|
||||
if (!instance) {
|
||||
return response.render("activitypub-explore", {
|
||||
title: response.locals.__("activitypub.reader.explore.title"),
|
||||
instance: rawInstance,
|
||||
scope,
|
||||
items: [],
|
||||
maxId: null,
|
||||
error: response.locals.__("activitypub.reader.explore.invalidInstance"),
|
||||
mountPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch public timeline from remote instance
|
||||
const isLocal = scope === "local";
|
||||
const apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
|
||||
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
|
||||
apiUrl.searchParams.set("limit", String(MAX_RESULTS));
|
||||
if (maxId) apiUrl.searchParams.set("max_id", maxId);
|
||||
|
||||
let items = [];
|
||||
let nextMaxId = null;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const fetchRes = await fetch(apiUrl.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!fetchRes.ok) {
|
||||
throw new Error(`Remote instance returned HTTP ${fetchRes.status}`);
|
||||
}
|
||||
|
||||
const statuses = await fetchRes.json();
|
||||
|
||||
if (!Array.isArray(statuses)) {
|
||||
throw new Error("Unexpected API response format");
|
||||
}
|
||||
|
||||
items = statuses.map((s) => mapMastodonStatusToItem(s, instance));
|
||||
|
||||
// Get next max_id from last item for pagination
|
||||
if (statuses.length === MAX_RESULTS && statuses.length > 0) {
|
||||
const last = statuses[statuses.length - 1];
|
||||
nextMaxId = last.id || null;
|
||||
}
|
||||
} catch (fetchError) {
|
||||
const msg = fetchError.name === "AbortError"
|
||||
? response.locals.__("activitypub.reader.explore.timeout")
|
||||
: response.locals.__("activitypub.reader.explore.loadError");
|
||||
error = msg;
|
||||
}
|
||||
|
||||
response.render("activitypub-explore", {
|
||||
title: response.locals.__("activitypub.reader.explore.title"),
|
||||
instance,
|
||||
scope,
|
||||
items,
|
||||
maxId: nextMaxId,
|
||||
error,
|
||||
mountPath,
|
||||
// Pass empty interactionMap — explore posts are not in our DB
|
||||
interactionMap: {},
|
||||
csrfToken: "",
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX API endpoint for explore page infinite scroll.
|
||||
* Returns JSON { html, maxId }.
|
||||
*/
|
||||
export function exploreApiController(mountPath) {
|
||||
return async (request, response, next) => {
|
||||
try {
|
||||
const rawInstance = request.query.instance || "";
|
||||
const scope = request.query.scope === "federated" ? "federated" : "local";
|
||||
const maxId = request.query.max_id || "";
|
||||
|
||||
const instance = validateInstance(rawInstance);
|
||||
if (!instance) {
|
||||
return response.status(400).json({ error: "Invalid instance" });
|
||||
}
|
||||
|
||||
const isLocal = scope === "local";
|
||||
const apiUrl = new URL(`https://${instance}/api/v1/timelines/public`);
|
||||
apiUrl.searchParams.set("local", isLocal ? "true" : "false");
|
||||
apiUrl.searchParams.set("limit", String(MAX_RESULTS));
|
||||
if (maxId) apiUrl.searchParams.set("max_id", maxId);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const fetchRes = await fetch(apiUrl.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!fetchRes.ok) {
|
||||
return response.status(502).json({ error: `Remote returned ${fetchRes.status}` });
|
||||
}
|
||||
|
||||
const statuses = await fetchRes.json();
|
||||
if (!Array.isArray(statuses)) {
|
||||
return response.status(502).json({ error: "Unexpected API response" });
|
||||
}
|
||||
|
||||
const items = statuses.map((s) => mapMastodonStatusToItem(s, instance));
|
||||
|
||||
let nextMaxId = null;
|
||||
if (statuses.length === MAX_RESULTS && statuses.length > 0) {
|
||||
const last = statuses[statuses.length - 1];
|
||||
nextMaxId = last.id || null;
|
||||
}
|
||||
|
||||
// Render each card server-side
|
||||
const templateData = {
|
||||
...response.locals,
|
||||
mountPath,
|
||||
csrfToken: "",
|
||||
interactionMap: {},
|
||||
};
|
||||
|
||||
const htmlParts = await Promise.all(
|
||||
items.map((item) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.app.render(
|
||||
"partials/ap-item-card.njk",
|
||||
{ ...templateData, item },
|
||||
(err, html) => {
|
||||
if (err) reject(err);
|
||||
else resolve(html);
|
||||
}
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
response.json({
|
||||
html: htmlParts.join(""),
|
||||
maxId: nextMaxId,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
62
lib/controllers/follow-tag.js
Normal file
62
lib/controllers/follow-tag.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Hashtag follow/unfollow controllers
|
||||
*/
|
||||
|
||||
import { validateToken } from "../csrf.js";
|
||||
import { followTag, unfollowTag } from "../storage/followed-tags.js";
|
||||
|
||||
export function followTagController(mountPath) {
|
||||
return async (request, response, next) => {
|
||||
try {
|
||||
const { application } = request.app.locals;
|
||||
|
||||
// CSRF validation
|
||||
if (!validateToken(request)) {
|
||||
return response.status(403).json({ error: "Invalid CSRF token" });
|
||||
}
|
||||
|
||||
const tag = typeof request.body.tag === "string" ? request.body.tag.trim() : "";
|
||||
if (!tag) {
|
||||
return response.redirect(`${mountPath}/admin/reader`);
|
||||
}
|
||||
|
||||
const collections = {
|
||||
ap_followed_tags: application?.collections?.get("ap_followed_tags"),
|
||||
};
|
||||
|
||||
await followTag(collections, tag);
|
||||
|
||||
return response.redirect(`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}`);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function unfollowTagController(mountPath) {
|
||||
return async (request, response, next) => {
|
||||
try {
|
||||
const { application } = request.app.locals;
|
||||
|
||||
// CSRF validation
|
||||
if (!validateToken(request)) {
|
||||
return response.status(403).json({ error: "Invalid CSRF token" });
|
||||
}
|
||||
|
||||
const tag = typeof request.body.tag === "string" ? request.body.tag.trim() : "";
|
||||
if (!tag) {
|
||||
return response.redirect(`${mountPath}/admin/reader`);
|
||||
}
|
||||
|
||||
const collections = {
|
||||
ap_followed_tags: application?.collections?.get("ap_followed_tags"),
|
||||
};
|
||||
|
||||
await unfollowTag(collections, tag);
|
||||
|
||||
return response.redirect(`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}`);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getBlockedUrls,
|
||||
getFilterMode,
|
||||
} from "../storage/moderation.js";
|
||||
import { getFollowedTags } from "../storage/followed-tags.js";
|
||||
|
||||
// Re-export controllers from split modules for backward compatibility
|
||||
export {
|
||||
@@ -38,6 +39,7 @@ export function readerController(mountPath) {
|
||||
const collections = {
|
||||
ap_timeline: application?.collections?.get("ap_timeline"),
|
||||
ap_notifications: application?.collections?.get("ap_notifications"),
|
||||
ap_followed_tags: application?.collections?.get("ap_followed_tags"),
|
||||
};
|
||||
|
||||
// Query parameters
|
||||
@@ -191,6 +193,14 @@ export function readerController(mountPath) {
|
||||
// CSRF token for interaction forms
|
||||
const csrfToken = getToken(request.session);
|
||||
|
||||
// Followed tags for sidebar
|
||||
let followedTags = [];
|
||||
try {
|
||||
followedTags = await getFollowedTags(collections);
|
||||
} catch {
|
||||
// Non-critical — collection may not exist yet
|
||||
}
|
||||
|
||||
response.render("activitypub-reader", {
|
||||
title: response.locals.__("activitypub.reader.title"),
|
||||
items,
|
||||
@@ -201,6 +211,7 @@ export function readerController(mountPath) {
|
||||
interactionMap,
|
||||
csrfToken,
|
||||
mountPath,
|
||||
followedTags,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
147
lib/controllers/tag-timeline.js
Normal file
147
lib/controllers/tag-timeline.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Tag timeline controller — shows posts from the timeline filtered by a specific hashtag.
|
||||
*/
|
||||
|
||||
import { getTimelineItems } from "../storage/timeline.js";
|
||||
import { getToken } from "../csrf.js";
|
||||
import {
|
||||
getMutedUrls,
|
||||
getMutedKeywords,
|
||||
getBlockedUrls,
|
||||
getFilterMode,
|
||||
} from "../storage/moderation.js";
|
||||
|
||||
export function tagTimelineController(mountPath) {
|
||||
return async (request, response, next) => {
|
||||
try {
|
||||
const { application } = request.app.locals;
|
||||
const collections = {
|
||||
ap_timeline: application?.collections?.get("ap_timeline"),
|
||||
};
|
||||
|
||||
// Validate tag parameter
|
||||
const tag = typeof request.query.tag === "string" ? request.query.tag.trim() : "";
|
||||
if (!tag) {
|
||||
return response.redirect(`${mountPath}/admin/reader`);
|
||||
}
|
||||
|
||||
const before = request.query.before;
|
||||
const after = request.query.after;
|
||||
const limit = Math.min(
|
||||
Number.isFinite(Number.parseInt(request.query.limit, 10))
|
||||
? Number.parseInt(request.query.limit, 10)
|
||||
: 20,
|
||||
100
|
||||
);
|
||||
|
||||
// Get timeline items filtered by tag
|
||||
const result = await getTimelineItems(collections, { before, after, limit, tag });
|
||||
let items = result.items;
|
||||
|
||||
// Apply moderation filters (same as main reader)
|
||||
const modCollections = {
|
||||
ap_muted: application?.collections?.get("ap_muted"),
|
||||
ap_blocked: application?.collections?.get("ap_blocked"),
|
||||
ap_profile: application?.collections?.get("ap_profile"),
|
||||
};
|
||||
const [mutedUrls, mutedKeywords, blockedUrls, filterMode] =
|
||||
await Promise.all([
|
||||
getMutedUrls(modCollections),
|
||||
getMutedKeywords(modCollections),
|
||||
getBlockedUrls(modCollections),
|
||||
getFilterMode(modCollections),
|
||||
]);
|
||||
const blockedSet = new Set(blockedUrls);
|
||||
const mutedSet = new Set(mutedUrls);
|
||||
|
||||
if (blockedSet.size > 0 || mutedSet.size > 0 || mutedKeywords.length > 0) {
|
||||
items = items.filter((item) => {
|
||||
if (item.author?.url && blockedSet.has(item.author.url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isMutedActor = item.author?.url && mutedSet.has(item.author.url);
|
||||
|
||||
let matchedKeyword = null;
|
||||
if (mutedKeywords.length > 0) {
|
||||
const searchable = [item.content?.text, item.name, item.summary]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
if (searchable) {
|
||||
matchedKeyword = mutedKeywords.find((kw) =>
|
||||
searchable.includes(kw.toLowerCase())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMutedActor || matchedKeyword) {
|
||||
if (filterMode === "warn") {
|
||||
item._moderated = true;
|
||||
item._moderationReason = isMutedActor ? "muted_account" : "muted_keyword";
|
||||
if (matchedKeyword) item._moderationKeyword = matchedKeyword;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Get interaction state for liked/boosted indicators
|
||||
const interactionsCol = application?.collections?.get("ap_interactions");
|
||||
const interactionMap = {};
|
||||
|
||||
if (interactionsCol) {
|
||||
const lookupUrls = new Set();
|
||||
const objectUrlToUid = new Map();
|
||||
|
||||
for (const item of items) {
|
||||
const uid = item.uid;
|
||||
const displayUrl = item.url || item.originalUrl;
|
||||
if (uid) { lookupUrls.add(uid); objectUrlToUid.set(uid, uid); }
|
||||
if (displayUrl) { lookupUrls.add(displayUrl); objectUrlToUid.set(displayUrl, uid || displayUrl); }
|
||||
}
|
||||
|
||||
if (lookupUrls.size > 0) {
|
||||
const interactions = await interactionsCol
|
||||
.find({ objectUrl: { $in: [...lookupUrls] } })
|
||||
.toArray();
|
||||
|
||||
for (const interaction of interactions) {
|
||||
const key = objectUrlToUid.get(interaction.objectUrl) || interaction.objectUrl;
|
||||
if (!interactionMap[key]) interactionMap[key] = {};
|
||||
interactionMap[key][interaction.type] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this hashtag is followed (Task 7 will populate ap_followed_tags)
|
||||
const followedTagsCol = application?.collections?.get("ap_followed_tags");
|
||||
let isFollowed = false;
|
||||
if (followedTagsCol) {
|
||||
const followed = await followedTagsCol.findOne({
|
||||
tag: { $regex: new RegExp(`^${tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") }
|
||||
});
|
||||
isFollowed = !!followed;
|
||||
}
|
||||
|
||||
const csrfToken = getToken(request.session);
|
||||
|
||||
response.render("activitypub-tag-timeline", {
|
||||
title: `#${tag}`,
|
||||
tag,
|
||||
items,
|
||||
before: result.before,
|
||||
after: result.after,
|
||||
interactionMap,
|
||||
csrfToken,
|
||||
mountPath,
|
||||
isFollowed,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline
|
||||
import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
|
||||
import { addNotification } from "./storage/notifications.js";
|
||||
import { fetchAndStorePreviews } from "./og-unfurl.js";
|
||||
import { getFollowedTags } from "./storage/followed-tags.js";
|
||||
|
||||
/**
|
||||
* Register all inbox listeners on a federation's inbox chain.
|
||||
@@ -492,6 +493,32 @@ export function registerInboxListeners(inboxChain, options) {
|
||||
// Log extraction errors but don't fail the entire handler
|
||||
console.error("Failed to store timeline item:", error);
|
||||
}
|
||||
} else if (collections.ap_followed_tags) {
|
||||
// Not a followed account — check if the post's hashtags match any followed tags
|
||||
// so tagged posts from across the fediverse appear in the timeline
|
||||
try {
|
||||
const objectTags = Array.isArray(object.tag) ? object.tag : (object.tag ? [object.tag] : []);
|
||||
const postHashtags = objectTags
|
||||
.filter((t) => t.type === "Hashtag" && t.name)
|
||||
.map((t) => t.name.toString().replace(/^#/, "").toLowerCase());
|
||||
|
||||
if (postHashtags.length > 0) {
|
||||
const followedTags = await getFollowedTags(collections);
|
||||
const followedSet = new Set(followedTags.map((t) => t.toLowerCase()));
|
||||
const hasMatchingTag = postHashtags.some((tag) => followedSet.has(tag));
|
||||
|
||||
if (hasMatchingTag) {
|
||||
const timelineItem = await extractObjectData(object, {
|
||||
actorFallback: actorObj,
|
||||
documentLoader: authLoader,
|
||||
});
|
||||
await addTimelineItem(collections, timelineItem);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-critical — don't fail the handler
|
||||
console.error("[inbox] Followed tag check failed:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
88
lib/migrations/separate-mentions.js
Normal file
88
lib/migrations/separate-mentions.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Migration: separate-mentions
|
||||
*
|
||||
* Moves @-prefixed entries from category[] to a new mentions[] array in all
|
||||
* ap_timeline documents. Tracked in ap_kv for idempotency.
|
||||
*
|
||||
* Before: category: ["@user@instance", "hashtag", "@another@host"]
|
||||
* After: category: ["hashtag"]
|
||||
* mentions: [{ name: "user@instance", url: "" }, { name: "another@host", url: "" }]
|
||||
*
|
||||
* Note: URLs are empty for legacy items since we can't reconstruct them.
|
||||
* New items will have URLs populated by the fixed extractObjectData() (Task 1).
|
||||
*/
|
||||
|
||||
const MIGRATION_KEY = "migration:separate-mentions";
|
||||
|
||||
/**
|
||||
* Run the separate-mentions migration (idempotent)
|
||||
* @param {object} collections - MongoDB collections
|
||||
* @returns {Promise<{ skipped: boolean, updated: number }>}
|
||||
*/
|
||||
export async function runSeparateMentionsMigration(collections) {
|
||||
const { ap_kv, ap_timeline } = collections;
|
||||
|
||||
// Check if already completed
|
||||
const state = await ap_kv.findOne({ _id: MIGRATION_KEY });
|
||||
if (state?.value?.completed) {
|
||||
return { skipped: true, updated: 0 };
|
||||
}
|
||||
|
||||
// Find all documents where category[] contains @-prefixed entries
|
||||
const docs = await ap_timeline
|
||||
.find({ category: { $regex: /^@/ } })
|
||||
.toArray();
|
||||
|
||||
if (docs.length === 0) {
|
||||
// No docs to migrate — mark complete immediately
|
||||
await ap_kv.updateOne(
|
||||
{ _id: MIGRATION_KEY },
|
||||
{ $set: { value: { completed: true, date: new Date().toISOString(), updated: 0 } } },
|
||||
{ upsert: true }
|
||||
);
|
||||
return { skipped: false, updated: 0 };
|
||||
}
|
||||
|
||||
// Build bulk operations
|
||||
const ops = docs.map((doc) => {
|
||||
const mentions = (doc.mentions || []).slice(); // preserve any existing mentions
|
||||
const newCategory = [];
|
||||
|
||||
for (const entry of doc.category || []) {
|
||||
if (typeof entry === "string" && entry.startsWith("@")) {
|
||||
// Move to mentions[] — strip leading @ to match timeline-store convention
|
||||
const strippedName = entry.slice(1);
|
||||
const alreadyPresent = mentions.some((m) => m.name === strippedName);
|
||||
if (!alreadyPresent) {
|
||||
mentions.push({ name: strippedName, url: "" });
|
||||
}
|
||||
} else {
|
||||
newCategory.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updateOne: {
|
||||
filter: { _id: doc._id },
|
||||
update: {
|
||||
$set: {
|
||||
category: newCategory,
|
||||
mentions
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const result = await ap_timeline.bulkWrite(ops, { ordered: false });
|
||||
const updated = result.modifiedCount || 0;
|
||||
|
||||
// Mark migration complete
|
||||
await ap_kv.updateOne(
|
||||
{ _id: MIGRATION_KEY },
|
||||
{ $set: { value: { completed: true, date: new Date().toISOString(), updated } } },
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
return { skipped: false, updated };
|
||||
}
|
||||
65
lib/storage/followed-tags.js
Normal file
65
lib/storage/followed-tags.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Followed hashtag storage operations
|
||||
* @module storage/followed-tags
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get all followed hashtags
|
||||
* @param {object} collections - MongoDB collections
|
||||
* @returns {Promise<string[]>} Array of tag strings (lowercase)
|
||||
*/
|
||||
export async function getFollowedTags(collections) {
|
||||
const { ap_followed_tags } = collections;
|
||||
if (!ap_followed_tags) return [];
|
||||
const docs = await ap_followed_tags.find({}).sort({ followedAt: -1 }).toArray();
|
||||
return docs.map((d) => d.tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a hashtag
|
||||
* @param {object} collections - MongoDB collections
|
||||
* @param {string} tag - Hashtag string (without # prefix)
|
||||
* @returns {Promise<boolean>} true if newly added, false if already following
|
||||
*/
|
||||
export async function followTag(collections, tag) {
|
||||
const { ap_followed_tags } = collections;
|
||||
const normalizedTag = tag.toLowerCase().trim().replace(/^#/, "");
|
||||
if (!normalizedTag) return false;
|
||||
|
||||
const result = await ap_followed_tags.updateOne(
|
||||
{ tag: normalizedTag },
|
||||
{ $setOnInsert: { tag: normalizedTag, followedAt: new Date().toISOString() } },
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
return result.upsertedCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfollow a hashtag
|
||||
* @param {object} collections - MongoDB collections
|
||||
* @param {string} tag - Hashtag string (without # prefix)
|
||||
* @returns {Promise<boolean>} true if removed, false if not found
|
||||
*/
|
||||
export async function unfollowTag(collections, tag) {
|
||||
const { ap_followed_tags } = collections;
|
||||
const normalizedTag = tag.toLowerCase().trim().replace(/^#/, "");
|
||||
if (!normalizedTag) return false;
|
||||
|
||||
const result = await ap_followed_tags.deleteOne({ tag: normalizedTag });
|
||||
return result.deletedCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific hashtag is followed
|
||||
* @param {object} collections - MongoDB collections
|
||||
* @param {string} tag - Hashtag string (without # prefix)
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function isTagFollowed(collections, tag) {
|
||||
const { ap_followed_tags } = collections;
|
||||
if (!ap_followed_tags) return false;
|
||||
const normalizedTag = tag.toLowerCase().trim().replace(/^#/, "");
|
||||
const doc = await ap_followed_tags.findOne({ tag: normalizedTag });
|
||||
return !!doc;
|
||||
}
|
||||
@@ -16,13 +16,14 @@
|
||||
* @param {boolean} item.sensitive - Sensitive content flag
|
||||
* @param {Date} item.published - Published date (kept as Date for sort queries)
|
||||
* @param {object} item.author - { name, url, photo, handle }
|
||||
* @param {string[]} item.category - Tags/categories
|
||||
* @param {string[]} item.category - Hashtag strings (# prefix stripped)
|
||||
* @param {Array<{name: string, url: string}>} [item.mentions] - @mention entries with actor URLs
|
||||
* @param {string[]} item.photo - Photo URLs
|
||||
* @param {string[]} item.video - Video URLs
|
||||
* @param {string[]} item.audio - Audio URLs
|
||||
* @param {string} [item.inReplyTo] - Parent post URL
|
||||
* @param {object} [item.boostedBy] - { name, url, photo, handle } for boosts
|
||||
* @param {Date} [item.boostedAt] - Boost timestamp
|
||||
* @param {string} [item.boostedAt] - Boost timestamp (ISO string)
|
||||
* @param {string} [item.originalUrl] - Original post URL for boosts
|
||||
* @param {Array<{url: string, title: string, description: string, image: string, favicon: string, domain: string, fetchedAt: string}>} [item.linkPreviews] - OpenGraph link previews for external links in content
|
||||
* @param {string} item.createdAt - ISO string creation timestamp
|
||||
@@ -59,6 +60,7 @@ export async function addTimelineItem(collections, item) {
|
||||
* @param {number} [options.limit=20] - Items per page
|
||||
* @param {string} [options.type] - Filter by type
|
||||
* @param {string} [options.authorUrl] - Filter by author URL
|
||||
* @param {string} [options.tag] - Filter by hashtag (case-insensitive exact match)
|
||||
* @returns {Promise<object>} { items, before, after }
|
||||
*/
|
||||
export async function getTimelineItems(collections, options = {}) {
|
||||
@@ -94,6 +96,17 @@ export async function getTimelineItems(collections, options = {}) {
|
||||
query["author.url"] = options.authorUrl;
|
||||
}
|
||||
|
||||
// Tag filter — case-insensitive exact match against the category[] array
|
||||
// Escape regex special chars to prevent injection
|
||||
if (options.tag) {
|
||||
if (typeof options.tag !== "string") {
|
||||
throw new Error("Invalid tag");
|
||||
}
|
||||
|
||||
const escapedTag = options.tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
query.category = { $regex: new RegExp(`^${escapedTag}$`, "i") };
|
||||
}
|
||||
|
||||
// Cursor pagination — published is stored as ISO string, so compare
|
||||
// as strings (lexicographic ISO 8601 comparison is correct for dates)
|
||||
if (options.before) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @module timeline-store
|
||||
*/
|
||||
|
||||
import { Article } from "@fedify/fedify/vocab";
|
||||
import { Article, Hashtag, Mention } from "@fedify/fedify/vocab";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
/**
|
||||
@@ -93,7 +93,9 @@ export async function extractActorInfo(actor, options = {}) {
|
||||
* @param {Date} [options.boostedAt] - Boost timestamp
|
||||
* @param {object} [options.actorFallback] - Fedify actor to use when object.getAttributedTo() fails
|
||||
* @param {object} [options.documentLoader] - Authenticated DocumentLoader for Secure Mode servers
|
||||
* @returns {Promise<object>} Timeline item data
|
||||
* @returns {Promise<object>} Timeline item data with:
|
||||
* - category: string[] — hashtag names (stripped of # prefix)
|
||||
* - mentions: Array<{name: string, url: string}> — @mention entries with actor URLs
|
||||
*/
|
||||
export async function extractObjectData(object, options = {}) {
|
||||
if (!object) {
|
||||
@@ -185,15 +187,25 @@ export async function extractObjectData(object, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract tags/categories — Fedify uses async getTags()
|
||||
// Extract tags — Fedify uses async getTags() which returns typed vocab objects.
|
||||
// Hashtag → category[] (plain strings, # prefix stripped)
|
||||
// Mention → mentions[] ({ name, url } objects for profile linking)
|
||||
const category = [];
|
||||
const mentions = [];
|
||||
try {
|
||||
if (typeof object.getTags === "function") {
|
||||
const tags = await object.getTags(loaderOpts);
|
||||
for await (const tag of tags) {
|
||||
if (tag.name) {
|
||||
const tagName = tag.name.toString().replace(/^#/, "");
|
||||
if (tag instanceof Hashtag) {
|
||||
const tagName = tag.name?.toString().replace(/^#/, "") || "";
|
||||
if (tagName) category.push(tagName);
|
||||
} else if (tag instanceof Mention) {
|
||||
// Strip leading @ from name (Fedify Mention names start with @)
|
||||
const rawName = tag.name?.toString() || "";
|
||||
const mentionName = rawName.startsWith("@") ? rawName.slice(1) : rawName;
|
||||
// tag.href is a URL object — use .href to get the string
|
||||
const mentionUrl = tag.href?.href || "";
|
||||
if (mentionName) mentions.push({ name: mentionName, url: mentionUrl });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,6 +255,7 @@ export async function extractObjectData(object, options = {}) {
|
||||
published,
|
||||
author,
|
||||
category,
|
||||
mentions,
|
||||
photo,
|
||||
video,
|
||||
audio,
|
||||
|
||||
@@ -185,10 +185,6 @@
|
||||
"boosts": "Boosts",
|
||||
"media": "Media"
|
||||
},
|
||||
"pagination": {
|
||||
"newer": "← Newer",
|
||||
"older": "Older →"
|
||||
},
|
||||
"empty": "Your timeline is empty. Follow some accounts to see their posts here.",
|
||||
"boosted": "boosted",
|
||||
"replyingTo": "Replying to",
|
||||
@@ -228,6 +224,33 @@
|
||||
},
|
||||
"linkPreview": {
|
||||
"label": "Link preview"
|
||||
},
|
||||
"explore": {
|
||||
"title": "Explore",
|
||||
"description": "Browse public timelines from remote Mastodon-compatible instances.",
|
||||
"instancePlaceholder": "Enter an instance hostname, e.g. mastodon.social",
|
||||
"browse": "Browse",
|
||||
"local": "Local",
|
||||
"federated": "Federated",
|
||||
"loadError": "Could not load timeline from this instance. It may be unavailable or not support the Mastodon API.",
|
||||
"timeout": "Request timed out. The instance may be slow or unavailable.",
|
||||
"noResults": "No posts found on this instance's public timeline.",
|
||||
"invalidInstance": "Invalid instance hostname. Please enter a valid domain name."
|
||||
},
|
||||
"tagTimeline": {
|
||||
"postsTagged": "%d posts",
|
||||
"postsTagged_plural": "%d posts",
|
||||
"noPosts": "No posts found with #%s in your timeline.",
|
||||
"followTag": "Follow hashtag",
|
||||
"unfollowTag": "Unfollow hashtag",
|
||||
"following": "Following"
|
||||
},
|
||||
"pagination": {
|
||||
"newer": "← Newer",
|
||||
"older": "Older →",
|
||||
"loadMore": "Load more",
|
||||
"loading": "Loading…",
|
||||
"noMore": "You're all caught up."
|
||||
}
|
||||
},
|
||||
"myProfile": {
|
||||
|
||||
82
views/activitypub-explore.njk
Normal file
82
views/activitypub-explore.njk
Normal file
@@ -0,0 +1,82 @@
|
||||
{% extends "layouts/ap-reader.njk" %}
|
||||
|
||||
{% from "prose/macro.njk" import prose with context %}
|
||||
|
||||
{% block readercontent %}
|
||||
{# Page header #}
|
||||
<header class="ap-explore-header">
|
||||
<h2 class="ap-explore-header__title">{{ __("activitypub.reader.explore.title") }}</h2>
|
||||
<p class="ap-explore-header__desc">{{ __("activitypub.reader.explore.description") }}</p>
|
||||
</header>
|
||||
|
||||
{# Instance form #}
|
||||
<form action="{{ mountPath }}/admin/reader/explore" method="get" class="ap-explore-form">
|
||||
<div class="ap-explore-form__row">
|
||||
<input
|
||||
type="text"
|
||||
name="instance"
|
||||
value="{{ instance }}"
|
||||
class="ap-explore-form__input"
|
||||
placeholder="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
|
||||
aria-label="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
|
||||
autocomplete="off"
|
||||
required>
|
||||
<div class="ap-explore-form__scope">
|
||||
<label class="ap-explore-form__scope-label">
|
||||
<input type="radio" name="scope" value="local"
|
||||
{% if scope == "local" %}checked{% endif %}>
|
||||
{{ __("activitypub.reader.explore.local") }}
|
||||
</label>
|
||||
<label class="ap-explore-form__scope-label">
|
||||
<input type="radio" name="scope" value="federated"
|
||||
{% if scope == "federated" %}checked{% endif %}>
|
||||
{{ __("activitypub.reader.explore.federated") }}
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="ap-explore-form__btn">
|
||||
{{ __("activitypub.reader.explore.browse") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# Error state #}
|
||||
{% if error %}
|
||||
<div class="ap-explore-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{# Results #}
|
||||
{% if instance and not error %}
|
||||
{% if items.length > 0 %}
|
||||
<div class="ap-timeline ap-explore-timeline"
|
||||
id="ap-explore-timeline"
|
||||
data-instance="{{ instance }}"
|
||||
data-scope="{{ scope }}"
|
||||
data-mount-path="{{ mountPath }}"
|
||||
data-max-id="{{ maxId if maxId else '' }}">
|
||||
{% for item in items %}
|
||||
{% include "partials/ap-item-card.njk" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Infinite scroll for explore page #}
|
||||
{% if maxId %}
|
||||
<div class="ap-load-more"
|
||||
id="ap-explore-load-more"
|
||||
data-max-id="{{ maxId }}"
|
||||
data-instance="{{ instance }}"
|
||||
data-scope="{{ scope }}"
|
||||
x-data="apExploreScroll()"
|
||||
x-init="init()">
|
||||
<div class="ap-load-more__sentinel" x-ref="sentinel"></div>
|
||||
<button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
|
||||
<span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
|
||||
<span x-show="loading">{{ __("activitypub.reader.pagination.loading") }}</span>
|
||||
</button>
|
||||
<p class="ap-load-more__done" x-show="done" x-cloak>{{ __("activitypub.reader.pagination.noMore") }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif instance %}
|
||||
{{ prose({ text: __("activitypub.reader.explore.noResults") }) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -4,6 +4,23 @@
|
||||
{% from "prose/macro.njk" import prose with context %}
|
||||
|
||||
{% block readercontent %}
|
||||
{# Explore link #}
|
||||
<div class="ap-reader-tools">
|
||||
<a href="{{ mountPath }}/admin/reader/explore" class="ap-reader-tools__explore">
|
||||
🔭 {{ __("activitypub.reader.explore.title") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Followed tags #}
|
||||
{% if followedTags and followedTags.length > 0 %}
|
||||
<div class="ap-followed-tags">
|
||||
<span class="ap-followed-tags__label">{{ __("activitypub.reader.tagTimeline.following") }}:</span>
|
||||
{% for tag in followedTags %}
|
||||
<a href="{{ mountPath }}/admin/reader/tag?tag={{ tag | urlencode }}" class="ap-card__tag">#{{ tag }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Fediverse lookup #}
|
||||
<form action="{{ mountPath }}/admin/reader/resolve" method="get" class="ap-lookup">
|
||||
<input type="text" name="q" class="ap-lookup__input"
|
||||
@@ -36,15 +53,18 @@
|
||||
|
||||
{# Timeline items #}
|
||||
{% if items.length > 0 %}
|
||||
<div class="ap-timeline" data-mount-path="{{ mountPath }}">
|
||||
<div class="ap-timeline"
|
||||
id="ap-timeline"
|
||||
data-mount-path="{{ mountPath }}"
|
||||
data-before="{{ before if before else '' }}">
|
||||
{% for item in items %}
|
||||
{% include "partials/ap-item-card.njk" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Pagination #}
|
||||
{# Pagination (progressive enhancement — visible without JS, hidden when Alpine active) #}
|
||||
{% if before or after %}
|
||||
<nav class="ap-pagination">
|
||||
<nav class="ap-pagination ap-pagination--js-hidden" id="ap-reader-pagination">
|
||||
{% if after %}
|
||||
<a href="?tab={{ tab }}&after={{ after }}" class="ap-pagination__prev">
|
||||
{{ __("activitypub.reader.pagination.newer") }}
|
||||
@@ -57,6 +77,25 @@
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{# Infinite scroll load-more sentinel #}
|
||||
{% if before %}
|
||||
<div class="ap-load-more"
|
||||
id="ap-load-more"
|
||||
data-before="{{ before }}"
|
||||
data-tab="{{ tab }}"
|
||||
data-tag=""
|
||||
x-data="apInfiniteScroll()"
|
||||
x-init="init()"
|
||||
@ap-append-items.window="appendItems($event.detail)">
|
||||
<div class="ap-load-more__sentinel" x-ref="sentinel"></div>
|
||||
<button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
|
||||
<span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
|
||||
<span x-show="loading">{{ __("activitypub.reader.pagination.loading") }}</span>
|
||||
</button>
|
||||
<p class="ap-load-more__done" x-show="done" x-cloak>{{ __("activitypub.reader.pagination.noMore") }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ prose({ text: __("activitypub.reader.empty") }) }}
|
||||
{% endif %}
|
||||
|
||||
86
views/activitypub-tag-timeline.njk
Normal file
86
views/activitypub-tag-timeline.njk
Normal file
@@ -0,0 +1,86 @@
|
||||
{% extends "layouts/ap-reader.njk" %}
|
||||
|
||||
{% from "prose/macro.njk" import prose with context %}
|
||||
|
||||
{% block readercontent %}
|
||||
{# Tag header #}
|
||||
<header class="ap-tag-header">
|
||||
<div class="ap-tag-header__info">
|
||||
<h2 class="ap-tag-header__title">#{{ tag }}</h2>
|
||||
<p class="ap-tag-header__count">
|
||||
{{ __("activitypub.reader.tagTimeline.postsTagged", items.length) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ap-tag-header__actions">
|
||||
{% if isFollowed %}
|
||||
<form action="{{ mountPath }}/admin/reader/unfollow-tag" method="post" class="ap-tag-header__follow-form">
|
||||
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="tag" value="{{ tag }}">
|
||||
<button type="submit" class="ap-tag-header__unfollow-btn">
|
||||
{{ __("activitypub.reader.tagTimeline.unfollowTag") }}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form action="{{ mountPath }}/admin/reader/follow-tag" method="post" class="ap-tag-header__follow-form">
|
||||
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
||||
<input type="hidden" name="tag" value="{{ tag }}">
|
||||
<button type="submit" class="ap-tag-header__follow-btn">
|
||||
{{ __("activitypub.reader.tagTimeline.followTag") }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ mountPath }}/admin/reader" class="ap-tag-header__back">
|
||||
← {{ __("activitypub.reader.title") }}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# Timeline items #}
|
||||
{% if items.length > 0 %}
|
||||
<div class="ap-timeline"
|
||||
data-mount-path="{{ mountPath }}"
|
||||
data-tag="{{ tag }}"
|
||||
data-before="{{ before if before else '' }}">
|
||||
{% for item in items %}
|
||||
{% include "partials/ap-item-card.njk" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Pagination (progressive enhancement fallback — hidden when infinite scroll JS active) #}
|
||||
{% if before or after %}
|
||||
<nav class="ap-pagination ap-pagination--js-hidden" id="ap-tag-pagination">
|
||||
{% if after %}
|
||||
<a href="?tag={{ tag }}&after={{ after }}" class="ap-pagination__prev">
|
||||
{{ __("activitypub.reader.pagination.newer") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if before %}
|
||||
<a href="?tag={{ tag }}&before={{ before }}" class="ap-pagination__next">
|
||||
{{ __("activitypub.reader.pagination.older") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{# Infinite scroll sentinel (Task 5) #}
|
||||
{% if before %}
|
||||
<div class="ap-load-more"
|
||||
id="ap-load-more"
|
||||
data-before="{{ before }}"
|
||||
data-tab=""
|
||||
data-tag="{{ tag }}"
|
||||
x-data="apInfiniteScroll()"
|
||||
x-init="init()"
|
||||
@ap-append-items.window="appendItems($event.detail)">
|
||||
<div class="ap-load-more__sentinel" x-ref="sentinel"></div>
|
||||
<button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
|
||||
<span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
|
||||
<span x-show="loading">{{ __("activitypub.reader.pagination.loading") }}</span>
|
||||
</button>
|
||||
<p class="ap-load-more__done" x-show="done">{{ __("activitypub.reader.pagination.noMore") }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ prose({ text: __("activitypub.reader.tagTimeline.noPosts", tag) }) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,7 +1,10 @@
|
||||
{% extends "document.njk" %}
|
||||
|
||||
{% block content %}
|
||||
{# Alpine.js for client-side reactivity (CW toggles, interaction buttons) #}
|
||||
{# Infinite scroll component — must load before Alpine to register via alpine:init #}
|
||||
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-infinite-scroll.js"></script>
|
||||
|
||||
{# Alpine.js for client-side reactivity (CW toggles, interaction buttons, infinite scroll) #}
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
|
||||
|
||||
{# Reader stylesheet — loaded in body is fine for modern browsers #}
|
||||
|
||||
@@ -113,12 +113,27 @@
|
||||
{% include "partials/ap-item-media.njk" %}
|
||||
{% endif %}
|
||||
|
||||
{# Tags/categories #}
|
||||
{% if item.category and item.category.length > 0 %}
|
||||
{# Mentions and hashtags #}
|
||||
{% set hasMentions = item.mentions and item.mentions.length > 0 %}
|
||||
{% set hasHashtags = item.category and item.category.length > 0 %}
|
||||
{% if hasMentions or hasHashtags %}
|
||||
<div class="ap-card__tags">
|
||||
{% for tag in item.category %}
|
||||
<a href="?tag={{ tag }}" class="ap-card__tag">#{{ tag }}</a>
|
||||
{% endfor %}
|
||||
{# Mentions — render with @ prefix, link to profile view when URL available #}
|
||||
{% if hasMentions %}
|
||||
{% for mention in item.mentions %}
|
||||
{% if mention.url %}
|
||||
<a href="{{ mountPath }}/admin/reader/profile?url={{ mention.url | urlencode }}" class="ap-card__mention">@{{ mention.name }}</a>
|
||||
{% else %}
|
||||
<span class="ap-card__mention ap-card__mention--legacy">@{{ mention.name }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{# Hashtags — render with # prefix, link to tag timeline #}
|
||||
{% if hasHashtags %}
|
||||
{% for tag in item.category %}
|
||||
<a href="{{ mountPath }}/admin/reader/tag?tag={{ tag | urlencode }}" class="ap-card__tag">#{{ tag }}</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user