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;
|
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 */
|
/* Hashtag tab sources info line */
|
||||||
.ap-hashtag-sources {
|
.ap-hashtag-sources {
|
||||||
color: var(--color-on-offset);
|
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 { getToken } from "../csrf.js";
|
||||||
import { extractObjectData } from "../timeline-store.js";
|
import { extractObjectData } from "../timeline-store.js";
|
||||||
import { getCached, setCache } from "../lookup-cache.js";
|
import { getCached, setCache } from "../lookup-cache.js";
|
||||||
|
import { fetchAndStoreQuote } from "../og-unfurl.js";
|
||||||
|
|
||||||
// Load parent posts (inReplyTo chain) up to maxDepth levels
|
// Load parent posts (inReplyTo chain) up to maxDepth levels
|
||||||
async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxDepth = 5) {
|
async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxDepth = 5) {
|
||||||
@@ -315,6 +316,45 @@ export function postDetailController(mountPath, plugin) {
|
|||||||
// Continue with empty thread
|
// 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);
|
const csrfToken = getToken(request.session);
|
||||||
|
|
||||||
response.render("activitypub-post-detail", {
|
response.render("activitypub-post-detail", {
|
||||||
|
|||||||
+17
-1
@@ -27,7 +27,7 @@ import { logActivity as logActivityShared } from "./activity-log.js";
|
|||||||
import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";
|
import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";
|
||||||
import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
|
import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
|
||||||
import { addNotification } from "./storage/notifications.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";
|
import { getFollowedTags } from "./storage/followed-tags.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -361,6 +361,14 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await addTimelineItem(collections, timelineItem);
|
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) {
|
} catch (error) {
|
||||||
// Remote object unreachable (timeout, Authorized Fetch, deleted, etc.) — skip
|
// Remote object unreachable (timeout, Authorized Fetch, deleted, etc.) — skip
|
||||||
const cause = error?.cause?.code || error?.message || "unknown";
|
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);
|
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) {
|
} catch (error) {
|
||||||
// Log extraction errors but don't fail the entire handler
|
// Log extraction errors but don't fail the entire handler
|
||||||
console.error("Failed to store timeline item:", error);
|
console.error("Failed to store timeline item:", error);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { unfurl } from "unfurl.js";
|
import { unfurl } from "unfurl.js";
|
||||||
|
import { extractObjectData } from "./timeline-store.js";
|
||||||
|
|
||||||
const USER_AGENT =
|
const USER_AGENT =
|
||||||
"Mozilla/5.0 (compatible; Indiekit/1.0; +https://getindiekit.com)";
|
"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)
|
// In-reply-to — Fedify uses replyTargetId (non-fetching)
|
||||||
const inReplyTo = object.replyTargetId?.href || "";
|
const inReplyTo = object.replyTargetId?.href || "";
|
||||||
|
|
||||||
|
// Quote URL — Fedify reads quoteUrl / _misskey_quote / quoteUri
|
||||||
|
const quoteUrl = object.quoteUrl?.href || "";
|
||||||
|
|
||||||
// Build base timeline item
|
// Build base timeline item
|
||||||
const item = {
|
const item = {
|
||||||
uid,
|
uid,
|
||||||
@@ -260,6 +263,7 @@ export async function extractObjectData(object, options = {}) {
|
|||||||
video,
|
video,
|
||||||
audio,
|
audio,
|
||||||
inReplyTo,
|
inReplyTo,
|
||||||
|
quoteUrl,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Quoted post embed #}
|
||||||
|
{% include "partials/ap-quote-embed.njk" %}
|
||||||
|
|
||||||
{# Link previews #}
|
{# Link previews #}
|
||||||
{% include "partials/ap-link-preview.njk" %}
|
{% include "partials/ap-link-preview.njk" %}
|
||||||
|
|
||||||
@@ -106,6 +109,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Quoted post embed #}
|
||||||
|
{% include "partials/ap-quote-embed.njk" %}
|
||||||
|
|
||||||
{# Link previews #}
|
{# Link previews #}
|
||||||
{% include "partials/ap-link-preview.njk" %}
|
{% include "partials/ap-link-preview.njk" %}
|
||||||
|
|
||||||
|
|||||||
@@ -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