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.
This commit is contained in:
Ricardo
2026-02-21 18:32:12 +01:00
parent 313d5d414c
commit 5ff3197493
18 changed files with 1070 additions and 10 deletions

151
assets/reader-links.css Normal file
View File

@@ -0,0 +1,151 @@
/**
* OpenGraph link preview cards and AP link interception
* Styles for link preview cards in the ActivityPub reader
*/
/* Link preview container */
.ap-link-previews {
margin-top: var(--space-m);
display: flex;
flex-direction: column;
gap: var(--space-s);
}
/* Individual link preview card */
.ap-link-preview {
display: flex;
overflow: hidden;
border-radius: var(--border-radius-small);
border: 1px solid var(--color-neutral-lighter);
background-color: var(--color-offset);
text-decoration: none;
color: inherit;
transition: border-color 0.2s ease;
}
.ap-link-preview:hover {
border-color: var(--color-primary);
}
/* Text content area (left side) */
.ap-link-preview__text {
flex: 1;
padding: var(--space-s);
min-width: 0; /* Enable text truncation */
}
.ap-link-preview__title {
font-weight: 600;
font-size: 0.875rem;
color: var(--color-on-background);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-link-preview__desc {
font-size: 0.75rem;
color: var(--color-on-offset);
margin: var(--space-xs) 0 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ap-link-preview__domain {
font-size: 0.75rem;
color: var(--color-neutral);
margin: var(--space-s) 0 0;
display: flex;
align-items: center;
gap: 0.25rem;
}
.ap-link-preview__favicon {
width: 1rem;
height: 1rem;
display: inline-block;
}
/* Image area (right side) */
.ap-link-preview__image {
flex-shrink: 0;
width: 6rem;
height: 6rem;
}
.ap-link-preview__image img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Responsive - larger images on desktop */
@media (min-width: 640px) {
.ap-link-preview__image {
width: 8rem;
height: 8rem;
}
.ap-link-preview__title {
font-size: 1rem;
}
.ap-link-preview__desc {
font-size: 0.875rem;
}
}
/* Post detail thread view */
.ap-post-detail__back {
margin-bottom: var(--space-m);
}
.ap-post-detail__back-link {
font-size: 0.875rem;
color: var(--color-primary);
text-decoration: none;
}
.ap-post-detail__back-link:hover {
text-decoration: underline;
}
.ap-post-detail__section-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-neutral);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: var(--space-l) 0 var(--space-s);
padding-bottom: var(--space-xs);
border-bottom: 1px solid var(--color-neutral-lighter);
}
.ap-post-detail__main {
margin: var(--space-m) 0;
}
.ap-post-detail__parents,
.ap-post-detail__replies {
margin: var(--space-m) 0;
}
.ap-post-detail__parent-item,
.ap-post-detail__reply-item {
margin-bottom: var(--space-s);
}
/* Thread connector line between parent posts */
.ap-post-detail__parents .ap-post-detail__parent-item {
position: relative;
padding-left: var(--space-m);
border-left: 2px solid var(--color-neutral-lighter);
}
/* Main post highlight */
.ap-post-detail__main .ap-card {
border-left-width: 3px;
}

88
assets/reader-links.js Normal file
View File

@@ -0,0 +1,88 @@
/**
* 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();
}
})();