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:
Ricardo
2026-02-26 18:15:21 +01:00
parent 2c4ffeaba0
commit a4f72a588d
19 changed files with 1656 additions and 20 deletions

View 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();
},
}));
});

View File

@@ -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;
}

View File

@@ -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);

View 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
View 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);
}
};
}

View 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);
}
};
}

View File

@@ -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);

View 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);
}
};
}

View File

@@ -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);
}
}
})

View 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 };
}

View 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;
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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": {

View 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 %}

View File

@@ -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 %}

View 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 %}

View File

@@ -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 #}

View File

@@ -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 %}