Files
indiekit-endpoint-activitypub/assets/reader-links.js
Ricardo 5ff3197493 feat: add internal AP link resolution and OpenGraph card unfurling (v1.1.14)
Reader now resolves ActivityPub links internally instead of navigating
to external instances. Actor links open the profile view, post links
open a new post detail view with thread context (parent chain + replies).

External links in post content get rich preview cards (title, description,
image, favicon) fetched via unfurl.js at ingest time with fire-and-forget
async processing and concurrency limiting.

New files: post-detail controller, og-unfurl module, lookup-cache,
link preview template/CSS, client-side link interception JS.
Includes SSRF protection for OG fetching and GoToSocial URL support.
2026-02-21 18:32:12 +01:00

89 lines
2.5 KiB
JavaScript

/**
* Client-side AP link interception for internal navigation
* Redirects ActivityPub links to internal reader views
*/
(function () {
"use strict";
// Fediverse URL patterns that should open internally
const AP_URL_PATTERN =
/\/@[\w.-]+\/\d+|\/@[\w.-]+\/statuses\/[\w]+|\/users\/[\w.-]+\/statuses\/\d+|\/objects\/[\w-]+|\/notice\/[\w]+|\/notes\/[\w]+|\/post\/\d+|\/comment\/\d+|\/p\/[\w.-]+\/\d+/;
// Get mount path from DOM
function getMountPath() {
// Look for data-mount-path on reader container or header
const container = document.querySelector(
"[data-mount-path]",
);
return container ? container.dataset.mountPath : "/activitypub";
}
// Check if a link should be intercepted
function shouldInterceptLink(link) {
const href = link.getAttribute("href");
if (!href) return null;
const classes = link.className || "";
// Mention links → profile view
if (classes.includes("mention")) {
return { type: "profile", url: href };
}
// AP object URL patterns → post detail view
if (AP_URL_PATTERN.test(href)) {
return { type: "post", url: href };
}
return null;
}
// Handle link click
function handleLinkClick(event) {
const link = event.target.closest("a");
if (!link) return;
// Only intercept links inside post content
const contentDiv = link.closest(".ap-card__content");
if (!contentDiv) return;
const interception = shouldInterceptLink(link);
if (!interception) return;
// Prevent default navigation
event.preventDefault();
const mountPath = getMountPath();
const encodedUrl = encodeURIComponent(interception.url);
if (interception.type === "profile") {
window.location.href = `${mountPath}/admin/reader/profile?url=${encodedUrl}`;
} else if (interception.type === "post") {
window.location.href = `${mountPath}/admin/reader/post?url=${encodedUrl}`;
}
}
// Initialize on DOM ready
function init() {
// Use event delegation on timeline container
const timeline = document.querySelector(".ap-timeline");
if (timeline) {
timeline.addEventListener("click", handleLinkClick);
}
// Also set up on post detail view
const postDetail = document.querySelector(".ap-post-detail");
if (postDetail) {
postDetail.addEventListener("click", handleLinkClick);
}
}
// Run on DOMContentLoaded or immediately if already loaded
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();