mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
feat: render quoted posts as embedded cards in reader
Extract quoteUrl from Fedify Note objects (supports Mastodon, Misskey, Fedibird quote formats). Fetch quoted post data asynchronously on inbox receive and on-demand in post detail view. Render as rich embed card with author avatar, handle, content, and timestamp. Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
This commit is contained in:
@@ -2343,6 +2343,120 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Quote Embeds
|
||||
========================================================================== */
|
||||
|
||||
.ap-quote-embed {
|
||||
border: var(--border-width-thin) solid var(--color-outline);
|
||||
border-radius: var(--border-radius-small);
|
||||
margin-top: var(--space-s);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ap-quote-embed--pending {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.ap-quote-embed__link {
|
||||
color: inherit;
|
||||
display: block;
|
||||
padding: var(--space-s) var(--space-m);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ap-quote-embed__link:hover {
|
||||
background: var(--color-offset);
|
||||
}
|
||||
|
||||
.ap-quote-embed__author {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.ap-quote-embed__avatar {
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
height: 24px;
|
||||
object-fit: cover;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.ap-quote-embed__avatar--default {
|
||||
align-items: center;
|
||||
background: var(--color-offset);
|
||||
color: var(--color-on-offset);
|
||||
display: inline-flex;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ap-quote-embed__author-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ap-quote-embed__name {
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: var(--font-weight-bold);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ap-quote-embed__handle {
|
||||
color: var(--color-on-offset);
|
||||
font-size: var(--font-size-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ap-quote-embed__time {
|
||||
color: var(--color-on-offset);
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ap-quote-embed__title {
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin: 0 0 var(--space-xs);
|
||||
}
|
||||
|
||||
.ap-quote-embed__content {
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 6;
|
||||
color: var(--color-on-background);
|
||||
display: -webkit-box;
|
||||
font-size: var(--font-size-s);
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ap-quote-embed__content p {
|
||||
margin: 0 0 var(--space-xs);
|
||||
}
|
||||
|
||||
.ap-quote-embed__content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ap-quote-embed__media {
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.ap-quote-embed__photo {
|
||||
border-radius: var(--border-radius-small);
|
||||
max-height: 160px;
|
||||
max-width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Hashtag tab sources info line */
|
||||
.ap-hashtag-sources {
|
||||
color: var(--color-on-offset);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Article, Note, Person, Service, Application } from "@fedify/fedify/voca
|
||||
import { getToken } from "../csrf.js";
|
||||
import { extractObjectData } from "../timeline-store.js";
|
||||
import { getCached, setCache } from "../lookup-cache.js";
|
||||
import { fetchAndStoreQuote } from "../og-unfurl.js";
|
||||
|
||||
// Load parent posts (inReplyTo chain) up to maxDepth levels
|
||||
async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxDepth = 5) {
|
||||
@@ -315,6 +316,45 @@ export function postDetailController(mountPath, plugin) {
|
||||
// Continue with empty thread
|
||||
}
|
||||
|
||||
// On-demand quote enrichment: if item has quoteUrl but no quote data yet
|
||||
if (timelineItem.quoteUrl && !timelineItem.quote) {
|
||||
try {
|
||||
const handle = plugin.options.actor.handle;
|
||||
const qCtx = plugin._federation.createContext(
|
||||
new URL(plugin._publicationUrl),
|
||||
{ handle, publicationUrl: plugin._publicationUrl },
|
||||
);
|
||||
const qLoader = await qCtx.getDocumentLoader({ identifier: handle });
|
||||
|
||||
const quoteObject = await qCtx.lookupObject(new URL(timelineItem.quoteUrl), {
|
||||
documentLoader: qLoader,
|
||||
});
|
||||
|
||||
if (quoteObject) {
|
||||
const quoteData = await extractObjectData(quoteObject, { documentLoader: qLoader });
|
||||
timelineItem.quote = {
|
||||
url: quoteData.url || quoteData.uid,
|
||||
uid: quoteData.uid,
|
||||
author: quoteData.author,
|
||||
content: quoteData.content,
|
||||
published: quoteData.published,
|
||||
name: quoteData.name,
|
||||
photo: quoteData.photo?.slice(0, 1) || [],
|
||||
};
|
||||
|
||||
// Persist for future requests (fire-and-forget)
|
||||
if (timelineCol) {
|
||||
timelineCol.updateOne(
|
||||
{ $or: [{ uid: objectUrl }, { url: objectUrl }] },
|
||||
{ $set: { quote: timelineItem.quote } },
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[post-detail] Quote fetch failed for ${objectUrl}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const csrfToken = getToken(request.session);
|
||||
|
||||
response.render("activitypub-post-detail", {
|
||||
|
||||
@@ -27,7 +27,7 @@ import { logActivity as logActivityShared } from "./activity-log.js";
|
||||
import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";
|
||||
import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
|
||||
import { addNotification } from "./storage/notifications.js";
|
||||
import { fetchAndStorePreviews } from "./og-unfurl.js";
|
||||
import { fetchAndStorePreviews, fetchAndStoreQuote } from "./og-unfurl.js";
|
||||
import { getFollowedTags } from "./storage/followed-tags.js";
|
||||
|
||||
/**
|
||||
@@ -361,6 +361,14 @@ export function registerInboxListeners(inboxChain, options) {
|
||||
});
|
||||
|
||||
await addTimelineItem(collections, timelineItem);
|
||||
|
||||
// Fire-and-forget quote enrichment for boosted posts
|
||||
if (timelineItem.quoteUrl) {
|
||||
fetchAndStoreQuote(collections, timelineItem.uid, timelineItem.quoteUrl, ctx, authLoader)
|
||||
.catch((error) => {
|
||||
console.error(`[inbox] Quote fetch failed for ${timelineItem.uid}:`, error.message);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Remote object unreachable (timeout, Authorized Fetch, deleted, etc.) — skip
|
||||
const cause = error?.cause?.code || error?.message || "unknown";
|
||||
@@ -489,6 +497,14 @@ export function registerInboxListeners(inboxChain, options) {
|
||||
console.error(`[inbox] OG unfurl failed for ${timelineItem.uid}:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
// Fire-and-forget quote enrichment
|
||||
if (timelineItem.quoteUrl) {
|
||||
fetchAndStoreQuote(collections, timelineItem.uid, timelineItem.quoteUrl, ctx, authLoader)
|
||||
.catch((error) => {
|
||||
console.error(`[inbox] Quote fetch failed for ${timelineItem.uid}:`, error.message);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Log extraction errors but don't fail the entire handler
|
||||
console.error("Failed to store timeline item:", error);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { unfurl } from "unfurl.js";
|
||||
import { extractObjectData } from "./timeline-store.js";
|
||||
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (compatible; Indiekit/1.0; +https://getindiekit.com)";
|
||||
@@ -248,3 +249,39 @@ export async function fetchAndStorePreviews(collections, uid, html) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a quoted post's data and store it on the timeline item.
|
||||
* Fire-and-forget — caller does NOT await. Errors are caught and logged.
|
||||
* @param {object} collections - MongoDB collections
|
||||
* @param {string} uid - Timeline item UID (the quoting post)
|
||||
* @param {string} quoteUrl - URL of the quoted post
|
||||
* @param {object} ctx - Fedify context (for lookupObject)
|
||||
* @param {object} documentLoader - Authenticated DocumentLoader
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function fetchAndStoreQuote(collections, uid, quoteUrl, ctx, documentLoader) {
|
||||
try {
|
||||
const object = await ctx.lookupObject(new URL(quoteUrl), { documentLoader });
|
||||
if (!object) return;
|
||||
|
||||
const quoteData = await extractObjectData(object, { documentLoader });
|
||||
|
||||
const quote = {
|
||||
url: quoteData.url || quoteData.uid,
|
||||
uid: quoteData.uid,
|
||||
author: quoteData.author,
|
||||
content: quoteData.content,
|
||||
published: quoteData.published,
|
||||
name: quoteData.name,
|
||||
photo: quoteData.photo?.slice(0, 1) || [],
|
||||
};
|
||||
|
||||
await collections.ap_timeline.updateOne(
|
||||
{ uid },
|
||||
{ $set: { quote } },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[og-unfurl] Failed to fetch quote for ${uid}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +243,9 @@ export async function extractObjectData(object, options = {}) {
|
||||
// In-reply-to — Fedify uses replyTargetId (non-fetching)
|
||||
const inReplyTo = object.replyTargetId?.href || "";
|
||||
|
||||
// Quote URL — Fedify reads quoteUrl / _misskey_quote / quoteUri
|
||||
const quoteUrl = object.quoteUrl?.href || "";
|
||||
|
||||
// Build base timeline item
|
||||
const item = {
|
||||
uid,
|
||||
@@ -260,6 +263,7 @@ export async function extractObjectData(object, options = {}) {
|
||||
video,
|
||||
audio,
|
||||
inReplyTo,
|
||||
quoteUrl,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
@@ -91,6 +91,9 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Quoted post embed #}
|
||||
{% include "partials/ap-quote-embed.njk" %}
|
||||
|
||||
{# Link previews #}
|
||||
{% include "partials/ap-link-preview.njk" %}
|
||||
|
||||
@@ -106,6 +109,9 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Quoted post embed #}
|
||||
{% include "partials/ap-quote-embed.njk" %}
|
||||
|
||||
{# Link previews #}
|
||||
{% include "partials/ap-link-preview.njk" %}
|
||||
|
||||
|
||||
41
views/partials/ap-quote-embed.njk
Normal file
41
views/partials/ap-quote-embed.njk
Normal file
@@ -0,0 +1,41 @@
|
||||
{# Quoted post embed — renders when a post quotes another post #}
|
||||
{% if item.quote %}
|
||||
<div class="ap-quote-embed">
|
||||
<a href="{{ mountPath }}/admin/reader/post?url={{ item.quote.uid | urlencode }}" class="ap-quote-embed__link">
|
||||
<header class="ap-quote-embed__author">
|
||||
{% if item.quote.author.photo %}
|
||||
<img src="{{ item.quote.author.photo }}" alt="" class="ap-quote-embed__avatar" loading="lazy" crossorigin="anonymous">
|
||||
{% else %}
|
||||
<span class="ap-quote-embed__avatar ap-quote-embed__avatar--default">{{ item.quote.author.name[0] | upper if item.quote.author.name else "?" }}</span>
|
||||
{% endif %}
|
||||
<div class="ap-quote-embed__author-info">
|
||||
<div class="ap-quote-embed__name">{{ item.quote.author.name or "Unknown" }}</div>
|
||||
{% if item.quote.author.handle %}
|
||||
<div class="ap-quote-embed__handle">{{ item.quote.author.handle }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if item.quote.published %}
|
||||
<time datetime="{{ item.quote.published }}" class="ap-quote-embed__time">{{ item.quote.published | date("PPp") }}</time>
|
||||
{% endif %}
|
||||
</header>
|
||||
{% if item.quote.name %}
|
||||
<p class="ap-quote-embed__title">{{ item.quote.name }}</p>
|
||||
{% endif %}
|
||||
{% if item.quote.content and item.quote.content.html %}
|
||||
<div class="ap-quote-embed__content">{{ item.quote.content.html | safe }}</div>
|
||||
{% endif %}
|
||||
{% if item.quote.photo and item.quote.photo.length > 0 %}
|
||||
<div class="ap-quote-embed__media">
|
||||
<img src="{{ item.quote.photo[0] }}" alt="" loading="lazy" class="ap-quote-embed__photo">
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% elif item.quoteUrl %}
|
||||
{# Fallback: quote not yet fetched — show as styled link #}
|
||||
<div class="ap-quote-embed ap-quote-embed--pending">
|
||||
<a href="{{ mountPath }}/admin/reader/post?url={{ item.quoteUrl | urlencode }}" class="ap-quote-embed__link">
|
||||
Quoted post: {{ item.quoteUrl }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
Reference in New Issue
Block a user