fix: resolve Unknown authors, filter empty boosts, style mentions

- Add actorFallback option to extractObjectData() so the activity's
  actor is used when object.getAttributedTo() fails (Authorized Fetch,
  unreachable servers). Falls back to attributionIds for URL-based info.
- Pass create.getActor() as actorFallback in Create inbox listener.
- Skip storing boosts with no content (Lemmy/PieFed activity IDs).
- Add template guard to hide empty cards already in the database.
- Style @mention and hashtag links distinctly from prose content.
- Handle Mastodon's invisible/ellipsis URL span classes.
This commit is contained in:
Ricardo
2026-02-21 14:54:10 +01:00
parent 7e97ab7fbf
commit d395a1cc24
5 changed files with 84 additions and 5 deletions

View File

@@ -296,6 +296,40 @@
max-width: 100%;
}
/* @mentions — styled as subtle pills to distinguish from prose */
.ap-card__content .h-card,
.ap-card__content a.u-url.mention {
color: var(--color-on-offset);
font-size: var(--font-size-s);
text-decoration: none;
}
.ap-card__content a.u-url.mention:hover {
color: var(--color-primary);
text-decoration: underline;
}
/* Hashtag mentions — subtle tag styling */
.ap-card__content a.mention.hashtag {
color: var(--color-on-offset);
font-size: var(--font-size-s);
text-decoration: none;
}
.ap-card__content a.mention.hashtag:hover {
color: var(--color-primary);
text-decoration: underline;
}
/* Mastodon's invisible/ellipsis spans for long URLs */
.ap-card__content .invisible {
display: none;
}
.ap-card__content .ellipsis::after {
content: "…";
}
/* ==========================================================================
Content Warning
========================================================================== */

View File

@@ -321,6 +321,11 @@ export function registerInboxListeners(inboxChain, options) {
const object = await announce.getObject();
if (!object) return;
// Skip non-content objects (Lemmy/PieFed like/create activities
// that resolve to activity IDs instead of actual Note/Article posts)
const hasContent = object.content?.toString() || object.name?.toString();
if (!hasContent) return;
// Get booster actor info
const boosterActor = await announce.getActor();
const boosterInfo = await extractActorInfo(boosterActor);
@@ -446,7 +451,9 @@ export function registerInboxListeners(inboxChain, options) {
const following = await collections.ap_following.findOne({ actorUrl });
if (following) {
try {
const timelineItem = await extractObjectData(object);
const timelineItem = await extractObjectData(object, {
actorFallback: actorObj,
});
await addTimelineItem(collections, timelineItem);
} catch (error) {
// Log extraction errors but don't fail the entire handler

View File

@@ -87,6 +87,7 @@ export async function extractActorInfo(actor) {
* @param {object} options - Extraction options
* @param {object} [options.boostedBy] - Actor info for boosts
* @param {Date} [options.boostedAt] - Boost timestamp
* @param {object} [options.actorFallback] - Fedify actor to use when object.getAttributedTo() fails
* @returns {Promise<object>} Timeline item data
*/
export async function extractObjectData(object, options = {}) {
@@ -127,7 +128,7 @@ export async function extractObjectData(object, options = {}) {
? String(object.published)
: new Date().toISOString();
// Extract author — use async getAttributedTo() for Fedify objects
// Extract author — try multiple strategies in order of reliability
let authorObj = null;
try {
if (typeof object.getAttributedTo === "function") {
@@ -135,10 +136,39 @@ export async function extractObjectData(object, options = {}) {
authorObj = Array.isArray(attr) ? attr[0] : attr;
}
} catch {
// Fallback: try direct property access for plain objects
// getAttributedTo() failed (Authorized Fetch, unreachable, etc.)
}
// If getAttributedTo() returned nothing, use the actor from the wrapping activity
if (!authorObj && options.actorFallback) {
authorObj = options.actorFallback;
}
// Try direct property access for plain objects
if (!authorObj) {
authorObj = object.attribution || object.attributedTo || null;
}
const author = await extractActorInfo(authorObj);
let author;
if (authorObj) {
author = await extractActorInfo(authorObj);
} else {
// Last resort: use attributionIds (non-fetching) to get at least a URL
const attrIds = object.attributionIds;
if (attrIds && attrIds.length > 0) {
const authorUrl = attrIds[0].href;
const authorHostname = new URL(authorUrl).hostname;
// Extract username from URL pattern like /users/name or /@name
const pathMatch = new URL(authorUrl).pathname.match(/\/@?([^/]+)/);
const username = pathMatch ? pathMatch[1] : "";
author = {
name: username || authorHostname,
url: authorUrl,
photo: "",
handle: username ? `@${username}@${authorHostname}` : "",
};
} else {
author = { name: "Unknown", url: "", photo: "", handle: "" };
}
}
// Extract tags/categories
const category = [];

View File

@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "1.1.5",
"version": "1.1.6",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",

View File

@@ -1,5 +1,11 @@
{# Timeline item card partial - reusable across timeline and profile views #}
{# Skip empty cards (e.g. Lemmy/PieFed activity IDs with no actual content) #}
{% set hasCardContent = item.content and (item.content.html or item.content.text) %}
{% set hasCardTitle = item.name %}
{% set hasCardMedia = (item.photo and item.photo.length > 0) or (item.video and item.video.length > 0) or (item.audio and item.audio.length > 0) %}
{% if hasCardContent or hasCardTitle or hasCardMedia %}
<article class="ap-card{% if item.type %} ap-card--{{ item.type }}{% endif %}{% if item.inReplyTo %} ap-card--reply{% endif %}">
{# Boost header if this is a boosted post #}
{% if item.type == "boost" and item.boostedBy %}
@@ -167,3 +173,5 @@
<div x-show="error" x-text="error" class="ap-card__action-error" x-transition></div>
</footer>
</article>
{% endif %}{# end hasCardContent/hasCardTitle/hasCardMedia guard #}