From 120f2ee00eed3b6cebcfeca6f7538c524d8d1d80 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Mon, 2 Mar 2026 10:33:11 +0100 Subject: [PATCH] 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 --- assets/reader.css | 114 ++++++++++++++++++++++++++++++ lib/controllers/post-detail.js | 40 +++++++++++ lib/inbox-listeners.js | 18 ++++- lib/og-unfurl.js | 37 ++++++++++ lib/timeline-store.js | 4 ++ views/partials/ap-item-card.njk | 6 ++ views/partials/ap-quote-embed.njk | 41 +++++++++++ 7 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 views/partials/ap-quote-embed.njk diff --git a/assets/reader.css b/assets/reader.css index 72e578e..da42682 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -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); diff --git a/lib/controllers/post-detail.js b/lib/controllers/post-detail.js index 0bc3b67..c48af5e 100644 --- a/lib/controllers/post-detail.js +++ b/lib/controllers/post-detail.js @@ -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", { diff --git a/lib/inbox-listeners.js b/lib/inbox-listeners.js index 6c3c7c7..e59c62e 100644 --- a/lib/inbox-listeners.js +++ b/lib/inbox-listeners.js @@ -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); diff --git a/lib/og-unfurl.js b/lib/og-unfurl.js index 17edeac..9fb8c86 100644 --- a/lib/og-unfurl.js +++ b/lib/og-unfurl.js @@ -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} + */ +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}`); + } +} diff --git a/lib/timeline-store.js b/lib/timeline-store.js index f425c44..9056aec 100644 --- a/lib/timeline-store.js +++ b/lib/timeline-store.js @@ -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() }; diff --git a/views/partials/ap-item-card.njk b/views/partials/ap-item-card.njk index ed9e2b9..50705a9 100644 --- a/views/partials/ap-item-card.njk +++ b/views/partials/ap-item-card.njk @@ -91,6 +91,9 @@ {% endif %} + {# Quoted post embed #} + {% include "partials/ap-quote-embed.njk" %} + {# Link previews #} {% include "partials/ap-link-preview.njk" %} @@ -106,6 +109,9 @@ {% endif %} + {# Quoted post embed #} + {% include "partials/ap-quote-embed.njk" %} + {# Link previews #} {% include "partials/ap-link-preview.njk" %} diff --git a/views/partials/ap-quote-embed.njk b/views/partials/ap-quote-embed.njk new file mode 100644 index 0000000..59b6c04 --- /dev/null +++ b/views/partials/ap-quote-embed.njk @@ -0,0 +1,41 @@ +{# Quoted post embed — renders when a post quotes another post #} +{% if item.quote %} + +{% elif item.quoteUrl %} + {# Fallback: quote not yet fetched — show as styled link #} + +{% endif %}