mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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:
151
assets/reader-links.css
Normal file
151
assets/reader-links.css
Normal 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
88
assets/reader-links.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user