fix: reader UI fixes and correct Fedify API usage (v1.1.8→1.1.12)
- Fix Unknown authors by adding multi-strategy fallback chain in extractObjectData (getAttributedTo → actorFallback → attributionIds) - Fix empty boosts from Lemmy/PieFed by checking content before storing - Fix @mention/hashtag styling to stay inline instead of breaking layout - Fix compose reply to show sanitized HTML blockquote instead of raw text - Add default-checked syndication targets for AP and Bluesky - Use authenticated document loader for all lookupObject calls (fixes 401 errors on servers requiring Authorized Fetch) - Fix like handler 404 by using canonical AP uid for interactions instead of display URLs; add data-item-uid to card template - Fix profile bio showing Nunjucks macro source code by renaming summary→bio to avoid collision with Indiekit's summary macro - Fix Fedify API misuse in timeline-store.js: use instanceof Article (not string comparison), replyTargetId (not inReplyTo), getTags() and getAttachments() async methods (not sync property access) - Fix inbox-listeners.js: use replyTargetId instead of non-existent getInReplyTo(), use instanceof Article for Update handler - Add error logging to interaction catch blocks
This commit is contained in:
+24
-8
@@ -296,24 +296,40 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* @mentions — styled as subtle pills to distinguish from prose */
|
/* @mentions — keep inline, style as subtle links */
|
||||||
.ap-card__content .h-card,
|
.ap-card__content .h-card {
|
||||||
.ap-card__content a.u-url.mention {
|
display: inline;
|
||||||
color: var(--color-on-offset);
|
|
||||||
font-size: var(--font-size-s);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ap-card__content .h-card a,
|
||||||
|
.ap-card__content a.u-url.mention {
|
||||||
|
display: inline;
|
||||||
|
color: var(--color-on-offset);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__content .h-card a span,
|
||||||
|
.ap-card__content a.u-url.mention span {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__content .h-card a:hover,
|
||||||
.ap-card__content a.u-url.mention:hover {
|
.ap-card__content a.u-url.mention:hover {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hashtag mentions — subtle tag styling */
|
/* Hashtag mentions — keep inline, subtle styling */
|
||||||
.ap-card__content a.mention.hashtag {
|
.ap-card__content a.mention.hashtag {
|
||||||
|
display: inline;
|
||||||
color: var(--color-on-offset);
|
color: var(--color-on-offset);
|
||||||
font-size: var(--font-size-s);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__content a.mention.hashtag span {
|
||||||
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-card__content a.mention.hashtag:hover {
|
.ap-card__content a.mention.hashtag:hover {
|
||||||
|
|||||||
@@ -496,7 +496,13 @@ export default class ActivityPubEndpoint {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Resolve the remote actor to get their inbox
|
// Resolve the remote actor to get their inbox
|
||||||
const remoteActor = await ctx.lookupObject(actorUrl);
|
// Use authenticated document loader for servers requiring Authorized Fetch
|
||||||
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
|
identifier: handle,
|
||||||
|
});
|
||||||
|
const remoteActor = await ctx.lookupObject(actorUrl, {
|
||||||
|
documentLoader,
|
||||||
|
});
|
||||||
if (!remoteActor) {
|
if (!remoteActor) {
|
||||||
return { ok: false, error: "Could not resolve remote actor" };
|
return { ok: false, error: "Could not resolve remote actor" };
|
||||||
}
|
}
|
||||||
@@ -591,7 +597,13 @@ export default class ActivityPubEndpoint {
|
|||||||
{ handle, publicationUrl: this._publicationUrl },
|
{ handle, publicationUrl: this._publicationUrl },
|
||||||
);
|
);
|
||||||
|
|
||||||
const remoteActor = await ctx.lookupObject(actorUrl);
|
// Use authenticated document loader for servers requiring Authorized Fetch
|
||||||
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
|
identifier: handle,
|
||||||
|
});
|
||||||
|
const remoteActor = await ctx.lookupObject(actorUrl, {
|
||||||
|
documentLoader,
|
||||||
|
});
|
||||||
if (!remoteActor) {
|
if (!remoteActor) {
|
||||||
// Even if we can't resolve, remove locally
|
// Even if we can't resolve, remove locally
|
||||||
await this._collections.ap_following.deleteOne({ actorUrl });
|
await this._collections.ap_following.deleteOne({ actorUrl });
|
||||||
|
|||||||
@@ -227,8 +227,13 @@ async function processOneFollow(options, entry) {
|
|||||||
try {
|
try {
|
||||||
const ctx = federation.createContext(new URL(publicationUrl), { handle, publicationUrl });
|
const ctx = federation.createContext(new URL(publicationUrl), { handle, publicationUrl });
|
||||||
|
|
||||||
// Resolve the remote actor
|
// Resolve the remote actor (signed request for Authorized Fetch)
|
||||||
const remoteActor = await ctx.lookupObject(entry.actorUrl);
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
|
identifier: handle,
|
||||||
|
});
|
||||||
|
const remoteActor = await ctx.lookupObject(entry.actorUrl, {
|
||||||
|
documentLoader,
|
||||||
|
});
|
||||||
if (!remoteActor) {
|
if (!remoteActor) {
|
||||||
throw new Error("Could not resolve remote actor");
|
throw new Error("Could not resolve remote actor");
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-10
@@ -3,8 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Temporal } from "@js-temporal/polyfill";
|
import { Temporal } from "@js-temporal/polyfill";
|
||||||
import { getTimelineItem } from "../storage/timeline.js";
|
|
||||||
import { getToken, validateToken } from "../csrf.js";
|
import { getToken, validateToken } from "../csrf.js";
|
||||||
|
import { sanitizeContent } from "../timeline-store.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch syndication targets from the Micropub config endpoint.
|
* Fetch syndication targets from the Micropub config endpoint.
|
||||||
@@ -61,7 +61,12 @@ export function composeController(mountPath, plugin) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Try to find the post in our timeline first
|
// Try to find the post in our timeline first
|
||||||
replyContext = await getTimelineItem(collections, replyTo);
|
// Note: Timeline stores uid (canonical AP URL) and url (display URL).
|
||||||
|
// The card link passes the display URL, so search both fields.
|
||||||
|
const ap_timeline = collections.ap_timeline;
|
||||||
|
replyContext = ap_timeline
|
||||||
|
? await ap_timeline.findOne({ $or: [{ uid: replyTo }, { url: replyTo }] })
|
||||||
|
: null;
|
||||||
|
|
||||||
// If not in timeline, try to look up remotely
|
// If not in timeline, try to look up remotely
|
||||||
if (!replyContext && plugin._federation) {
|
if (!replyContext && plugin._federation) {
|
||||||
@@ -71,14 +76,22 @@ export function composeController(mountPath, plugin) {
|
|||||||
new URL(plugin._publicationUrl),
|
new URL(plugin._publicationUrl),
|
||||||
{ handle, publicationUrl: plugin._publicationUrl },
|
{ handle, publicationUrl: plugin._publicationUrl },
|
||||||
);
|
);
|
||||||
const remoteObject = await ctx.lookupObject(new URL(replyTo));
|
// Use authenticated document loader for Authorized Fetch
|
||||||
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
|
identifier: handle,
|
||||||
|
});
|
||||||
|
const remoteObject = await ctx.lookupObject(new URL(replyTo), {
|
||||||
|
documentLoader,
|
||||||
|
});
|
||||||
|
|
||||||
if (remoteObject) {
|
if (remoteObject) {
|
||||||
let authorName = "";
|
let authorName = "";
|
||||||
let authorUrl = "";
|
let authorUrl = "";
|
||||||
|
|
||||||
if (typeof remoteObject.getAttributedTo === "function") {
|
if (typeof remoteObject.getAttributedTo === "function") {
|
||||||
const author = await remoteObject.getAttributedTo();
|
const author = await remoteObject.getAttributedTo({
|
||||||
|
documentLoader,
|
||||||
|
});
|
||||||
const actor = Array.isArray(author) ? author[0] : author;
|
const actor = Array.isArray(author) ? author[0] : author;
|
||||||
|
|
||||||
if (actor) {
|
if (actor) {
|
||||||
@@ -90,18 +103,22 @@ export function composeController(mountPath, plugin) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawHtml = remoteObject.content?.toString() || "";
|
||||||
replyContext = {
|
replyContext = {
|
||||||
url: replyTo,
|
url: replyTo,
|
||||||
name: remoteObject.name?.toString() || "",
|
name: remoteObject.name?.toString() || "",
|
||||||
content: {
|
content: {
|
||||||
text:
|
html: sanitizeContent(rawHtml),
|
||||||
remoteObject.content?.toString()?.slice(0, 300) || "",
|
text: rawHtml.replace(/<[^>]*>/g, "").slice(0, 300),
|
||||||
},
|
},
|
||||||
author: { name: authorName, url: authorUrl },
|
author: { name: authorName, url: authorUrl },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Could not resolve — form still works without context
|
console.warn(
|
||||||
|
`[ActivityPub] lookupObject failed for ${replyTo} (compose):`,
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +129,13 @@ export function composeController(mountPath, plugin) {
|
|||||||
? await getSyndicationTargets(application, token)
|
? await getSyndicationTargets(application, token)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// Default-check only AP (Fedify) and Bluesky targets
|
||||||
|
// "@rick@rmendes.net" = AP Fedify, "@rmendes.net" = Bluesky
|
||||||
|
for (const target of syndicationTargets) {
|
||||||
|
const name = target.name || "";
|
||||||
|
target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net";
|
||||||
|
}
|
||||||
|
|
||||||
const csrfToken = getToken(request.session);
|
const csrfToken = getToken(request.session);
|
||||||
|
|
||||||
response.render("activitypub-compose", {
|
response.render("activitypub-compose", {
|
||||||
@@ -198,13 +222,20 @@ export function submitComposeController(mountPath, plugin) {
|
|||||||
// If replying, also send to the original author
|
// If replying, also send to the original author
|
||||||
if (inReplyTo) {
|
if (inReplyTo) {
|
||||||
try {
|
try {
|
||||||
const remoteObject = await ctx.lookupObject(new URL(inReplyTo));
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
|
identifier: handle,
|
||||||
|
});
|
||||||
|
const remoteObject = await ctx.lookupObject(new URL(inReplyTo), {
|
||||||
|
documentLoader,
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
remoteObject &&
|
remoteObject &&
|
||||||
typeof remoteObject.getAttributedTo === "function"
|
typeof remoteObject.getAttributedTo === "function"
|
||||||
) {
|
) {
|
||||||
const author = await remoteObject.getAttributedTo();
|
const author = await remoteObject.getAttributedTo({
|
||||||
|
documentLoader,
|
||||||
|
});
|
||||||
const recipient = Array.isArray(author)
|
const recipient = Array.isArray(author)
|
||||||
? author[0]
|
? author[0]
|
||||||
: author;
|
: author;
|
||||||
|
|||||||
@@ -57,15 +57,20 @@ export function boostController(mountPath, plugin) {
|
|||||||
orderingKey: url,
|
orderingKey: url,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also send to the original post author
|
// Also send to the original post author (signed request for Authorized Fetch)
|
||||||
try {
|
try {
|
||||||
const remoteObject = await ctx.lookupObject(new URL(url));
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
|
identifier: handle,
|
||||||
|
});
|
||||||
|
const remoteObject = await ctx.lookupObject(new URL(url), {
|
||||||
|
documentLoader,
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
remoteObject &&
|
remoteObject &&
|
||||||
typeof remoteObject.getAttributedTo === "function"
|
typeof remoteObject.getAttributedTo === "function"
|
||||||
) {
|
) {
|
||||||
const author = await remoteObject.getAttributedTo();
|
const author = await remoteObject.getAttributedTo({ documentLoader });
|
||||||
const recipient = Array.isArray(author) ? author[0] : author;
|
const recipient = Array.isArray(author) ? author[0] : author;
|
||||||
|
|
||||||
if (recipient) {
|
if (recipient) {
|
||||||
@@ -77,8 +82,11 @@ export function boostController(mountPath, plugin) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Non-critical — followers still received the boost
|
console.warn(
|
||||||
|
`[ActivityPub] lookupObject failed for ${url} (boost):`,
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track the interaction
|
// Track the interaction
|
||||||
|
|||||||
@@ -43,29 +43,57 @@ export function likeController(mountPath, plugin) {
|
|||||||
{ handle, publicationUrl: plugin._publicationUrl },
|
{ handle, publicationUrl: plugin._publicationUrl },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Look up the remote post to find its author
|
// Use authenticated document loader for servers requiring Authorized Fetch
|
||||||
const remoteObject = await ctx.lookupObject(new URL(url));
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
|
identifier: handle,
|
||||||
|
});
|
||||||
|
|
||||||
if (!remoteObject) {
|
// Resolve author for delivery — try multiple strategies
|
||||||
return response.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: "Could not resolve remote post",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the post author for delivery
|
|
||||||
let recipient = null;
|
let recipient = null;
|
||||||
|
|
||||||
if (typeof remoteObject.getAttributedTo === "function") {
|
// Strategy 1: Look up remote post via Fedify (signed request)
|
||||||
const author = await remoteObject.getAttributedTo();
|
try {
|
||||||
recipient = Array.isArray(author) ? author[0] : author;
|
const remoteObject = await ctx.lookupObject(new URL(url), {
|
||||||
|
documentLoader,
|
||||||
|
});
|
||||||
|
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
|
||||||
|
const author = await remoteObject.getAttributedTo({ documentLoader });
|
||||||
|
recipient = Array.isArray(author) ? author[0] : author;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[ActivityPub] lookupObject failed for ${url}:`,
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Use author URL from our timeline (already stored)
|
||||||
|
// Note: Timeline items store both uid (canonical AP URL) and url (display URL).
|
||||||
|
// The card passes the display URL, so we search by both fields.
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
return response.status(404).json({
|
const { application } = request.app.locals;
|
||||||
success: false,
|
const ap_timeline = application?.collections?.get("ap_timeline");
|
||||||
error: "Could not resolve post author",
|
const timelineItem = ap_timeline
|
||||||
});
|
? await ap_timeline.findOne({ $or: [{ uid: url }, { url }] })
|
||||||
|
: null;
|
||||||
|
const authorUrl = timelineItem?.author?.url;
|
||||||
|
|
||||||
|
if (authorUrl) {
|
||||||
|
try {
|
||||||
|
recipient = await ctx.lookupObject(new URL(authorUrl), {
|
||||||
|
documentLoader,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Could not resolve author actor either
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return response.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Could not resolve post author",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a unique activity ID
|
// Generate a unique activity ID
|
||||||
@@ -170,13 +198,45 @@ export function unlikeController(mountPath, plugin) {
|
|||||||
{ handle, publicationUrl: plugin._publicationUrl },
|
{ handle, publicationUrl: plugin._publicationUrl },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resolve the recipient
|
// Use authenticated document loader for servers requiring Authorized Fetch
|
||||||
const remoteObject = await ctx.lookupObject(new URL(url));
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
|
identifier: handle,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve the recipient — try remote first, then timeline fallback
|
||||||
let recipient = null;
|
let recipient = null;
|
||||||
|
|
||||||
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
|
try {
|
||||||
const author = await remoteObject.getAttributedTo();
|
const remoteObject = await ctx.lookupObject(new URL(url), {
|
||||||
recipient = Array.isArray(author) ? author[0] : author;
|
documentLoader,
|
||||||
|
});
|
||||||
|
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
|
||||||
|
const author = await remoteObject.getAttributedTo({ documentLoader });
|
||||||
|
recipient = Array.isArray(author) ? author[0] : author;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[ActivityPub] lookupObject failed for ${url} (unlike):`,
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
const ap_timeline = application?.collections?.get("ap_timeline");
|
||||||
|
const timelineItem = ap_timeline
|
||||||
|
? await ap_timeline.findOne({ $or: [{ uid: url }, { url }] })
|
||||||
|
: null;
|
||||||
|
const authorUrl = timelineItem?.author?.url;
|
||||||
|
|
||||||
|
if (authorUrl) {
|
||||||
|
try {
|
||||||
|
recipient = await ctx.lookupObject(new URL(authorUrl), {
|
||||||
|
documentLoader,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Could not resolve — will proceed to cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
|
|||||||
@@ -151,7 +151,12 @@ export function blockController(mountPath, plugin) {
|
|||||||
{ handle, publicationUrl: plugin._publicationUrl },
|
{ handle, publicationUrl: plugin._publicationUrl },
|
||||||
);
|
);
|
||||||
|
|
||||||
const remoteActor = await ctx.lookupObject(new URL(url));
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
|
identifier: handle,
|
||||||
|
});
|
||||||
|
const remoteActor = await ctx.lookupObject(new URL(url), {
|
||||||
|
documentLoader,
|
||||||
|
});
|
||||||
|
|
||||||
if (remoteActor) {
|
if (remoteActor) {
|
||||||
const block = new Block({
|
const block = new Block({
|
||||||
@@ -225,7 +230,12 @@ export function unblockController(mountPath, plugin) {
|
|||||||
{ handle, publicationUrl: plugin._publicationUrl },
|
{ handle, publicationUrl: plugin._publicationUrl },
|
||||||
);
|
);
|
||||||
|
|
||||||
const remoteActor = await ctx.lookupObject(new URL(url));
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
|
identifier: handle,
|
||||||
|
});
|
||||||
|
const remoteActor = await ctx.lookupObject(new URL(url), {
|
||||||
|
documentLoader,
|
||||||
|
});
|
||||||
|
|
||||||
if (remoteActor) {
|
if (remoteActor) {
|
||||||
const block = new Block({
|
const block = new Block({
|
||||||
|
|||||||
@@ -36,11 +36,14 @@ export function remoteProfileController(mountPath, plugin) {
|
|||||||
{ handle, publicationUrl: plugin._publicationUrl },
|
{ handle, publicationUrl: plugin._publicationUrl },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Look up the remote actor
|
// Look up the remote actor (signed request for Authorized Fetch)
|
||||||
|
const documentLoader = await ctx.getDocumentLoader({
|
||||||
|
identifier: handle,
|
||||||
|
});
|
||||||
let actor;
|
let actor;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
actor = await ctx.lookupObject(new URL(actorUrl));
|
actor = await ctx.lookupObject(new URL(actorUrl), { documentLoader });
|
||||||
} catch {
|
} catch {
|
||||||
return response.status(404).render("error", {
|
return response.status(404).render("error", {
|
||||||
title: "Error",
|
title: "Error",
|
||||||
@@ -61,7 +64,7 @@ export function remoteProfileController(mountPath, plugin) {
|
|||||||
actor.preferredUsername?.toString() ||
|
actor.preferredUsername?.toString() ||
|
||||||
actorUrl;
|
actorUrl;
|
||||||
const actorHandle = actor.preferredUsername?.toString() || "";
|
const actorHandle = actor.preferredUsername?.toString() || "";
|
||||||
const summary = sanitizeContent(actor.summary?.toString() || "");
|
const bio = sanitizeContent(actor.summary?.toString() || "");
|
||||||
let icon = "";
|
let icon = "";
|
||||||
let image = "";
|
let image = "";
|
||||||
|
|
||||||
@@ -126,7 +129,7 @@ export function remoteProfileController(mountPath, plugin) {
|
|||||||
actorUrl,
|
actorUrl,
|
||||||
name,
|
name,
|
||||||
actorHandle,
|
actorHandle,
|
||||||
summary,
|
bio,
|
||||||
icon,
|
icon,
|
||||||
image,
|
image,
|
||||||
instanceHost,
|
instanceHost,
|
||||||
|
|||||||
@@ -107,26 +107,47 @@ export function readerController(mountPath) {
|
|||||||
const unreadCount = await getUnreadNotificationCount(collections);
|
const unreadCount = await getUnreadNotificationCount(collections);
|
||||||
|
|
||||||
// Get interaction state for liked/boosted indicators
|
// Get interaction state for liked/boosted indicators
|
||||||
|
// Interactions are keyed by canonical AP uid (new) or display url (legacy).
|
||||||
|
// Query by both, normalize map keys to uid for template lookup.
|
||||||
const interactionsCol =
|
const interactionsCol =
|
||||||
application?.collections?.get("ap_interactions");
|
application?.collections?.get("ap_interactions");
|
||||||
const interactionMap = {};
|
const interactionMap = {};
|
||||||
|
|
||||||
if (interactionsCol) {
|
if (interactionsCol) {
|
||||||
const itemUrls = items
|
const lookupUrls = new Set();
|
||||||
.map((item) => item.url || item.originalUrl)
|
const objectUrlToUid = new Map();
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
if (itemUrls.length > 0) {
|
for (const item of items) {
|
||||||
|
const uid = item.uid;
|
||||||
|
const displayUrl = item.url || item.originalUrl;
|
||||||
|
|
||||||
|
if (uid) {
|
||||||
|
lookupUrls.add(uid);
|
||||||
|
objectUrlToUid.set(uid, uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayUrl) {
|
||||||
|
lookupUrls.add(displayUrl);
|
||||||
|
objectUrlToUid.set(displayUrl, uid || displayUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lookupUrls.size > 0) {
|
||||||
const interactions = await interactionsCol
|
const interactions = await interactionsCol
|
||||||
.find({ objectUrl: { $in: itemUrls } })
|
.find({ objectUrl: { $in: [...lookupUrls] } })
|
||||||
.toArray();
|
.toArray();
|
||||||
|
|
||||||
for (const interaction of interactions) {
|
for (const interaction of interactions) {
|
||||||
if (!interactionMap[interaction.objectUrl]) {
|
// Normalize to uid so template can look up by itemUid
|
||||||
interactionMap[interaction.objectUrl] = {};
|
const key =
|
||||||
|
objectUrlToUid.get(interaction.objectUrl) ||
|
||||||
|
interaction.objectUrl;
|
||||||
|
|
||||||
|
if (!interactionMap[key]) {
|
||||||
|
interactionMap[key] = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
interactionMap[interaction.objectUrl][interaction.type] = true;
|
interactionMap[key][interaction.type] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Accept,
|
Accept,
|
||||||
Add,
|
Add,
|
||||||
Announce,
|
Announce,
|
||||||
|
Article,
|
||||||
Block,
|
Block,
|
||||||
Create,
|
Create,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -365,14 +366,8 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
actorObj?.preferredUsername?.toString() ||
|
actorObj?.preferredUsername?.toString() ||
|
||||||
actorUrl;
|
actorUrl;
|
||||||
|
|
||||||
let inReplyTo = null;
|
// Use replyTargetId (non-fetching) for the inReplyTo URL
|
||||||
if (object instanceof Note && typeof object.getInReplyTo === "function") {
|
const inReplyTo = object.replyTargetId?.href || null;
|
||||||
try {
|
|
||||||
inReplyTo = (await object.getInReplyTo())?.id?.href ?? null;
|
|
||||||
} catch {
|
|
||||||
/* remote fetch may fail */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log replies to our posts (existing behavior for conversations)
|
// Log replies to our posts (existing behavior for conversations)
|
||||||
const pubUrl = collections._publicationUrl;
|
const pubUrl = collections._publicationUrl;
|
||||||
@@ -505,7 +500,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PATH 1: If object is a Note/Article → Update timeline item content
|
// PATH 1: If object is a Note/Article → Update timeline item content
|
||||||
if (object && (object instanceof Note || object.type === "Article")) {
|
if (object && (object instanceof Note || object instanceof Article)) {
|
||||||
const objectUrl = object.id?.href || "";
|
const objectUrl = object.id?.href || "";
|
||||||
if (objectUrl) {
|
if (objectUrl) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+33
-23
@@ -3,6 +3,7 @@
|
|||||||
* @module timeline-store
|
* @module timeline-store
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Article } from "@fedify/fedify";
|
||||||
import sanitizeHtml from "sanitize-html";
|
import sanitizeHtml from "sanitize-html";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,9 +99,9 @@ export async function extractObjectData(object, options = {}) {
|
|||||||
const uid = object.id?.href || "";
|
const uid = object.id?.href || "";
|
||||||
const url = object.url?.href || uid;
|
const url = object.url?.href || uid;
|
||||||
|
|
||||||
// Determine type
|
// Determine type — use instanceof for Fedify vocab objects
|
||||||
let type = "note";
|
let type = "note";
|
||||||
if (object.type?.toLowerCase() === "article") {
|
if (object instanceof Article) {
|
||||||
type = "article";
|
type = "article";
|
||||||
}
|
}
|
||||||
if (options.boostedBy) {
|
if (options.boostedBy) {
|
||||||
@@ -179,42 +180,51 @@ export async function extractObjectData(object, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract tags/categories
|
// Extract tags/categories — Fedify uses async getTags()
|
||||||
const category = [];
|
const category = [];
|
||||||
if (object.tag) {
|
try {
|
||||||
const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
|
if (typeof object.getTags === "function") {
|
||||||
for (const tag of tags) {
|
const tags = await object.getTags();
|
||||||
if (tag.type === "Hashtag" && tag.name) {
|
for (const tag of tags) {
|
||||||
category.push(tag.name.toString().replace(/^#/, ""));
|
if (tag.name) {
|
||||||
|
const tagName = tag.name.toString().replace(/^#/, "");
|
||||||
|
if (tagName) category.push(tagName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Tags extraction failed — non-critical
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract media attachments
|
// Extract media attachments — Fedify uses async getAttachments()
|
||||||
const photo = [];
|
const photo = [];
|
||||||
const video = [];
|
const video = [];
|
||||||
const audio = [];
|
const audio = [];
|
||||||
|
|
||||||
if (object.attachment) {
|
try {
|
||||||
const attachments = Array.isArray(object.attachment) ? object.attachment : [object.attachment];
|
if (typeof object.getAttachments === "function") {
|
||||||
for (const att of attachments) {
|
const attachments = await object.getAttachments();
|
||||||
const mediaUrl = att.url?.href || "";
|
for (const att of attachments) {
|
||||||
if (!mediaUrl) continue;
|
const mediaUrl = att.url?.href || "";
|
||||||
|
if (!mediaUrl) continue;
|
||||||
|
|
||||||
const mediaType = att.mediaType?.toLowerCase() || "";
|
const mediaType = att.mediaType?.toLowerCase() || "";
|
||||||
|
|
||||||
if (mediaType.startsWith("image/")) {
|
if (mediaType.startsWith("image/")) {
|
||||||
photo.push(mediaUrl);
|
photo.push(mediaUrl);
|
||||||
} else if (mediaType.startsWith("video/")) {
|
} else if (mediaType.startsWith("video/")) {
|
||||||
video.push(mediaUrl);
|
video.push(mediaUrl);
|
||||||
} else if (mediaType.startsWith("audio/")) {
|
} else if (mediaType.startsWith("audio/")) {
|
||||||
audio.push(mediaUrl);
|
audio.push(mediaUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Attachment extraction failed — non-critical
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-reply-to
|
// In-reply-to — Fedify uses replyTargetId (non-fetching)
|
||||||
const inReplyTo = object.inReplyTo?.href || "";
|
const inReplyTo = object.replyTargetId?.href || "";
|
||||||
|
|
||||||
// Build base timeline item
|
// Build base timeline item
|
||||||
const item = {
|
const item = {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
||||||
"version": "1.1.8",
|
"version": "1.1.12",
|
||||||
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"indiekit",
|
"indiekit",
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
<a href="{{ replyContext.author.url }}">{{ replyContext.author.name }}</a>
|
<a href="{{ replyContext.author.url }}">{{ replyContext.author.name }}</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if replyContext.content and replyContext.content.text %}
|
{% if replyContext.content and (replyContext.content.html or replyContext.content.text) %}
|
||||||
<blockquote class="ap-compose__context-text">
|
<blockquote class="ap-compose__context-text">
|
||||||
{{ replyContext.content.text | truncate(300) }}
|
{{ replyContext.content.html | safe if replyContext.content.html else replyContext.content.text | truncate(300) }}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ replyTo }}" class="ap-compose__context-link" target="_blank" rel="noopener">{{ replyTo }}</a>
|
<a href="{{ replyTo }}" class="ap-compose__context-link" target="_blank" rel="noopener">{{ replyTo }}</a>
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
<legend>{{ __("activitypub.compose.syndicateLabel") }}</legend>
|
<legend>{{ __("activitypub.compose.syndicateLabel") }}</legend>
|
||||||
{% for target in syndicationTargets %}
|
{% for target in syndicationTargets %}
|
||||||
<label class="ap-compose__syndication-target">
|
<label class="ap-compose__syndication-target">
|
||||||
<input type="checkbox" name="mp-syndicate-to" value="{{ target.uid }}" checked>
|
<input type="checkbox" name="mp-syndicate-to" value="{{ target.uid }}" {{ "checked" if target.defaultChecked }}>
|
||||||
{{ target.name }}
|
{{ target.name }}
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -57,8 +57,8 @@
|
|||||||
{% if actorHandle %}
|
{% if actorHandle %}
|
||||||
<div class="ap-profile__handle">@{{ actorHandle }}@{{ instanceHost }}</div>
|
<div class="ap-profile__handle">@{{ actorHandle }}@{{ instanceHost }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if summary %}
|
{% if bio %}
|
||||||
<div class="ap-profile__bio">{{ summary | safe }}</div>
|
<div class="ap-profile__bio">{{ bio | safe }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -97,11 +97,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Interaction buttons — Alpine.js for optimistic updates #}
|
{# Interaction buttons — Alpine.js for optimistic updates #}
|
||||||
{# Dynamic data moved to data-* attributes to prevent XSS from inline interpolation #}
|
{# Use canonical AP uid for interactions (Fedify lookupObject), display url for links #}
|
||||||
{% set itemUrl = item.url or item.originalUrl %}
|
{% set itemUrl = item.url or item.originalUrl %}
|
||||||
{% set isLiked = interactionMap[itemUrl].like if interactionMap[itemUrl] else false %}
|
{% set itemUid = item.uid or item.url or item.originalUrl %}
|
||||||
{% set isBoosted = interactionMap[itemUrl].boost if interactionMap[itemUrl] else false %}
|
{% set isLiked = interactionMap[itemUid].like if interactionMap[itemUid] else false %}
|
||||||
|
{% set isBoosted = interactionMap[itemUid].boost if interactionMap[itemUid] else false %}
|
||||||
<footer class="ap-card__actions"
|
<footer class="ap-card__actions"
|
||||||
|
data-item-uid="{{ itemUid }}"
|
||||||
data-item-url="{{ itemUrl }}"
|
data-item-url="{{ itemUrl }}"
|
||||||
data-csrf-token="{{ csrfToken }}"
|
data-csrf-token="{{ csrfToken }}"
|
||||||
data-mount-path="{{ mountPath }}"
|
data-mount-path="{{ mountPath }}"
|
||||||
@@ -115,7 +117,7 @@
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
const el = this.$root;
|
const el = this.$root;
|
||||||
const itemUrl = el.dataset.itemUrl;
|
const itemUid = el.dataset.itemUid;
|
||||||
const csrfToken = el.dataset.csrfToken;
|
const csrfToken = el.dataset.csrfToken;
|
||||||
const basePath = el.dataset.mountPath;
|
const basePath = el.dataset.mountPath;
|
||||||
const prev = { liked: this.liked, boosted: this.boosted };
|
const prev = { liked: this.liked, boosted: this.boosted };
|
||||||
@@ -130,7 +132,7 @@
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRF-Token': csrfToken
|
'X-CSRF-Token': csrfToken
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ url: itemUrl })
|
body: JSON.stringify({ url: itemUid })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
@@ -147,7 +149,7 @@
|
|||||||
if (this.error) setTimeout(() => this.error = '', 3000);
|
if (this.error) setTimeout(() => this.error = '', 3000);
|
||||||
}
|
}
|
||||||
}">
|
}">
|
||||||
<a href="{{ mountPath }}/admin/reader/compose?replyTo={{ itemUrl | urlencode }}"
|
<a href="{{ mountPath }}/admin/reader/compose?replyTo={{ itemUid | urlencode }}"
|
||||||
class="ap-card__action ap-card__action--reply"
|
class="ap-card__action ap-card__action--reply"
|
||||||
title="{{ __('activitypub.reader.actions.reply') }}">
|
title="{{ __('activitypub.reader.actions.reply') }}">
|
||||||
↩ {{ __("activitypub.reader.actions.reply") }}
|
↩ {{ __("activitypub.reader.actions.reply") }}
|
||||||
|
|||||||
Reference in New Issue
Block a user