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:
Ricardo
2026-02-21 17:08:28 +01:00
parent b81ecbcaa4
commit 313d5d414c
15 changed files with 280 additions and 107 deletions
+24 -8
View File
@@ -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 {
+14 -2
View File
@@ -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 });
+7 -2
View File
@@ -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
View File
@@ -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;
+13 -5
View File
@@ -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
+74 -14
View File
@@ -43,23 +43,50 @@ 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) {
return response.status(404).json({
success: false,
error: "Could not resolve remote post",
}); });
}
// Get the post author for delivery // Resolve author for delivery — try multiple strategies
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 {
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; 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) {
const { application } = request.app.locals;
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 author actor either
}
}
if (!recipient) { if (!recipient) {
return response.status(404).json({ return response.status(404).json({
@@ -67,6 +94,7 @@ export function likeController(mountPath, plugin) {
error: "Could not resolve post author", error: "Could not resolve post author",
}); });
} }
}
// Generate a unique activity ID // Generate a unique activity ID
const activityId = `urn:uuid:${crypto.randomUUID()}`; const activityId = `urn:uuid:${crypto.randomUUID()}`;
@@ -170,14 +198,46 @@ 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;
try {
const remoteObject = await ctx.lookupObject(new URL(url), {
documentLoader,
});
if (remoteObject && typeof remoteObject.getAttributedTo === "function") { if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
const author = await remoteObject.getAttributedTo(); const author = await remoteObject.getAttributedTo({ documentLoader });
recipient = Array.isArray(author) ? author[0] : author; 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) {
// Clean up the local record even if we can't send Undo // Clean up the local record even if we can't send Undo
+12 -2
View File
@@ -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({
+7 -4
View File
@@ -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,
+29 -8
View File
@@ -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;
} }
} }
} }
+4 -9
View File
@@ -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 {
+22 -12
View File
@@ -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,24 +180,30 @@ 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") {
const tags = await object.getTags();
for (const tag of tags) { for (const tag of tags) {
if (tag.type === "Hashtag" && tag.name) { if (tag.name) {
category.push(tag.name.toString().replace(/^#/, "")); 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") {
const attachments = await object.getAttachments();
for (const att of attachments) { for (const att of attachments) {
const mediaUrl = att.url?.href || ""; const mediaUrl = att.url?.href || "";
if (!mediaUrl) continue; if (!mediaUrl) continue;
@@ -212,9 +219,12 @@ export async function extractObjectData(object, options = {}) {
} }
} }
} }
} 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
View File
@@ -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",
+3 -3
View File
@@ -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 %}
+2 -2
View File
@@ -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>
+8 -6
View File
@@ -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") }}