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:
@@ -296,6 +296,40 @@
|
|||||||
max-width: 100%;
|
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
|
Content Warning
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|||||||
@@ -321,6 +321,11 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
const object = await announce.getObject();
|
const object = await announce.getObject();
|
||||||
if (!object) return;
|
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
|
// Get booster actor info
|
||||||
const boosterActor = await announce.getActor();
|
const boosterActor = await announce.getActor();
|
||||||
const boosterInfo = await extractActorInfo(boosterActor);
|
const boosterInfo = await extractActorInfo(boosterActor);
|
||||||
@@ -446,7 +451,9 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
const following = await collections.ap_following.findOne({ actorUrl });
|
const following = await collections.ap_following.findOne({ actorUrl });
|
||||||
if (following) {
|
if (following) {
|
||||||
try {
|
try {
|
||||||
const timelineItem = await extractObjectData(object);
|
const timelineItem = await extractObjectData(object, {
|
||||||
|
actorFallback: actorObj,
|
||||||
|
});
|
||||||
await addTimelineItem(collections, timelineItem);
|
await addTimelineItem(collections, timelineItem);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log extraction errors but don't fail the entire handler
|
// Log extraction errors but don't fail the entire handler
|
||||||
|
|||||||
+33
-3
@@ -87,6 +87,7 @@ export async function extractActorInfo(actor) {
|
|||||||
* @param {object} options - Extraction options
|
* @param {object} options - Extraction options
|
||||||
* @param {object} [options.boostedBy] - Actor info for boosts
|
* @param {object} [options.boostedBy] - Actor info for boosts
|
||||||
* @param {Date} [options.boostedAt] - Boost timestamp
|
* @param {Date} [options.boostedAt] - Boost timestamp
|
||||||
|
* @param {object} [options.actorFallback] - Fedify actor to use when object.getAttributedTo() fails
|
||||||
* @returns {Promise<object>} Timeline item data
|
* @returns {Promise<object>} Timeline item data
|
||||||
*/
|
*/
|
||||||
export async function extractObjectData(object, options = {}) {
|
export async function extractObjectData(object, options = {}) {
|
||||||
@@ -127,7 +128,7 @@ export async function extractObjectData(object, options = {}) {
|
|||||||
? String(object.published)
|
? String(object.published)
|
||||||
: new Date().toISOString();
|
: new Date().toISOString();
|
||||||
|
|
||||||
// Extract author — use async getAttributedTo() for Fedify objects
|
// Extract author — try multiple strategies in order of reliability
|
||||||
let authorObj = null;
|
let authorObj = null;
|
||||||
try {
|
try {
|
||||||
if (typeof object.getAttributedTo === "function") {
|
if (typeof object.getAttributedTo === "function") {
|
||||||
@@ -135,10 +136,39 @@ export async function extractObjectData(object, options = {}) {
|
|||||||
authorObj = Array.isArray(attr) ? attr[0] : attr;
|
authorObj = Array.isArray(attr) ? attr[0] : attr;
|
||||||
}
|
}
|
||||||
} catch {
|
} 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;
|
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
|
// Extract tags/categories
|
||||||
const category = [];
|
const category = [];
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"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.",
|
"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",
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
{# Timeline item card partial - reusable across timeline and profile views #}
|
{# 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 %}">
|
<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 #}
|
{# Boost header if this is a boosted post #}
|
||||||
{% if item.type == "boost" and item.boostedBy %}
|
{% 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>
|
<div x-show="error" x-text="error" class="ap-card__action-error" x-transition></div>
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{% endif %}{# end hasCardContent/hasCardTitle/hasCardMedia guard #}
|
||||||
|
|||||||
Reference in New Issue
Block a user