diff --git a/assets/reader-infinite-scroll.js b/assets/reader-infinite-scroll.js new file mode 100644 index 0000000..1bee4eb --- /dev/null +++ b/assets/reader-infinite-scroll.js @@ -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(); + }, + })); +}); diff --git a/assets/reader.css b/assets/reader.css index 04e685d..f3cfb10 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -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; +} diff --git a/index.js b/index.js index a61e91d..d171e88 100644 --- a/index.js +++ b/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); diff --git a/lib/controllers/api-timeline.js b/lib/controllers/api-timeline.js new file mode 100644 index 0000000..23b3d24 --- /dev/null +++ b/lib/controllers/api-timeline.js @@ -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); + } + }; +} diff --git a/lib/controllers/explore.js b/lib/controllers/explore.js new file mode 100644 index 0000000..a0a164f --- /dev/null +++ b/lib/controllers/explore.js @@ -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); + } + }; +} diff --git a/lib/controllers/follow-tag.js b/lib/controllers/follow-tag.js new file mode 100644 index 0000000..98f0e18 --- /dev/null +++ b/lib/controllers/follow-tag.js @@ -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); + } + }; +} diff --git a/lib/controllers/reader.js b/lib/controllers/reader.js index 874351f..b05c721 100644 --- a/lib/controllers/reader.js +++ b/lib/controllers/reader.js @@ -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); diff --git a/lib/controllers/tag-timeline.js b/lib/controllers/tag-timeline.js new file mode 100644 index 0000000..9f10e0f --- /dev/null +++ b/lib/controllers/tag-timeline.js @@ -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); + } + }; +} diff --git a/lib/inbox-listeners.js b/lib/inbox-listeners.js index e3e6bc0..6c3c7c7 100644 --- a/lib/inbox-listeners.js +++ b/lib/inbox-listeners.js @@ -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); + } } }) diff --git a/lib/migrations/separate-mentions.js b/lib/migrations/separate-mentions.js new file mode 100644 index 0000000..4ce1952 --- /dev/null +++ b/lib/migrations/separate-mentions.js @@ -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 }; +} diff --git a/lib/storage/followed-tags.js b/lib/storage/followed-tags.js new file mode 100644 index 0000000..53ec79d --- /dev/null +++ b/lib/storage/followed-tags.js @@ -0,0 +1,65 @@ +/** + * Followed hashtag storage operations + * @module storage/followed-tags + */ + +/** + * Get all followed hashtags + * @param {object} collections - MongoDB collections + * @returns {Promise} 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} 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} 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} + */ +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; +} diff --git a/lib/storage/timeline.js b/lib/storage/timeline.js index f495e92..751473f 100644 --- a/lib/storage/timeline.js +++ b/lib/storage/timeline.js @@ -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} { 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) { diff --git a/lib/timeline-store.js b/lib/timeline-store.js index f0ffb2b..f425c44 100644 --- a/lib/timeline-store.js +++ b/lib/timeline-store.js @@ -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} Timeline item data + * @returns {Promise} 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, diff --git a/locales/en.json b/locales/en.json index 0424366..c133460 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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": { diff --git a/views/activitypub-explore.njk b/views/activitypub-explore.njk new file mode 100644 index 0000000..2852711 --- /dev/null +++ b/views/activitypub-explore.njk @@ -0,0 +1,82 @@ +{% extends "layouts/ap-reader.njk" %} + +{% from "prose/macro.njk" import prose with context %} + +{% block readercontent %} + {# Page header #} +
+

{{ __("activitypub.reader.explore.title") }}

+

{{ __("activitypub.reader.explore.description") }}

+
+ + {# Instance form #} +
+
+ +
+ + +
+ +
+
+ + {# Error state #} + {% if error %} +
{{ error }}
+ {% endif %} + + {# Results #} + {% if instance and not error %} + {% if items.length > 0 %} +
+ {% for item in items %} + {% include "partials/ap-item-card.njk" %} + {% endfor %} +
+ + {# Infinite scroll for explore page #} + {% if maxId %} +
+
+ +

{{ __("activitypub.reader.pagination.noMore") }}

+
+ {% endif %} + {% elif instance %} + {{ prose({ text: __("activitypub.reader.explore.noResults") }) }} + {% endif %} + {% endif %} +{% endblock %} diff --git a/views/activitypub-reader.njk b/views/activitypub-reader.njk index 1bdbcd0..b5776ea 100644 --- a/views/activitypub-reader.njk +++ b/views/activitypub-reader.njk @@ -4,6 +4,23 @@ {% from "prose/macro.njk" import prose with context %} {% block readercontent %} + {# Explore link #} + + + {# Followed tags #} + {% if followedTags and followedTags.length > 0 %} +
+ {{ __("activitypub.reader.tagTimeline.following") }}: + {% for tag in followedTags %} + #{{ tag }} + {% endfor %} +
+ {% endif %} + {# Fediverse lookup #}
0 %} -
+
{% for item in items %} {% include "partials/ap-item-card.njk" %} {% endfor %}
- {# Pagination #} + {# Pagination (progressive enhancement — visible without JS, hidden when Alpine active) #} {% if before or after %} -