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:
Ricardo
2026-03-02 10:33:11 +01:00
parent abf1b94bd6
commit 120f2ee00e
7 changed files with 259 additions and 1 deletions

View File

@@ -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);

View File

@@ -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", {

View File

@@ -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);

View File

@@ -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}`);
}
}

View File

@@ -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()
};

View File

@@ -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" %}

View 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 %}