fix(mastodon): profile avatar lost after first enrichment; actor published non-UTC

Two profile display fixes:

1. Avatar not persisting across requests: resolveRemoteAccount fetches
   the correct avatar via lookupWithSecurity, but only updated the
   in-memory serialized status — never the DB or the cache. On the next
   request serializeStatus rebuilt the account from item.author.photo
   (empty if the actor was on a Secure Mode server when the item arrived),
   and enrichAccountStats skipped re-fetching because follower counts
   were already > 0. Fix: include avatarUrl in cacheAccountStats; in
   collectAccount always check the cache first (for avatar + createdAt)
   regardless of whether counts are already populated.

2. actor.published may not be UTC: Temporal.Instant.toString() preserves
   the original timezone offset from the AP actor object; wrap in
   new Date(...).toISOString() so created_at is always UTC ISO 8601.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-23 08:30:25 +01:00
parent a259c79a31
commit da89554ef9
3 changed files with 27 additions and 12 deletions

View File

@@ -11,7 +11,7 @@ import { remoteActorId } from "./id-mapping.js";
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const MAX_ENTRIES = 500;
// Map<actorUrl, { followersCount, followingCount, statusesCount, createdAt, cachedAt }>
// Map<actorUrl, { followersCount, followingCount, statusesCount, createdAt, avatarUrl, cachedAt }>
const cache = new Map();
// Reverse map: accountId (hash) → actorUrl
@@ -21,7 +21,7 @@ const idToUrl = new Map();
/**
* Store account stats in cache.
* @param {string} actorUrl - The actor's URL (cache key)
* @param {object} stats - { followersCount, followingCount, statusesCount, createdAt }
* @param {object} stats - { followersCount, followingCount, statusesCount, createdAt, avatarUrl }
*/
export function cacheAccountStats(actorUrl, stats) {
if (!actorUrl) return;

View File

@@ -77,18 +77,27 @@ export async function enrichAccountStats(statuses, pluginOptions, baseUrl) {
*/
function collectAccount(account, map) {
if (!account?.url) return;
if (account.followers_count > 0 || account.statuses_count > 0) return;
// Check cache first — if cached, apply immediately
// Always check cache first — applies avatar + createdAt even for already-enriched accounts.
// avatarUrl is stored in the cache by resolveRemoteAccount so it survives across requests
// even when the timeline item's author.photo is empty (e.g. actor was on a Secure Mode
// server when the item was originally received).
const cached = getCachedAccountStats(account.url);
if (cached) {
account.followers_count = cached.followersCount || 0;
account.following_count = cached.followingCount || 0;
account.statuses_count = cached.statusesCount || 0;
account.followers_count = cached.followersCount || account.followers_count || 0;
account.following_count = cached.followingCount || account.following_count || 0;
account.statuses_count = cached.statusesCount || account.statuses_count || 0;
if (cached.createdAt) account.created_at = cached.createdAt;
if (cached.avatarUrl) {
account.avatar = cached.avatarUrl;
account.avatar_static = cached.avatarUrl;
}
return;
}
// Skip remote resolution if counts are already populated from some other source
if (account.followers_count > 0 || account.statuses_count > 0) return;
// Queue for remote resolution
if (!map.has(account.url)) {
map.set(account.url, []);

View File

@@ -86,10 +86,15 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl) {
if (outbox?.totalItems != null) statusesCount = outbox.totalItems;
} catch { /* ignore */ }
// Get published/created date
const published = actor.published
? String(actor.published)
: null;
// Get published/created date — normalize to UTC ISO so clients display it correctly.
// Temporal.Instant.toString() preserves the original timezone offset;
// passing through new Date() converts to "YYYY-MM-DDTHH:mm:ss.sssZ".
let published = null;
if (actor.published) {
try {
published = new Date(String(actor.published)).toISOString();
} catch { /* ignore unparseable dates */ }
}
// Profile fields from attachments
const fields = [];
@@ -124,12 +129,13 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl) {
account.following_count = followingCount;
account.statuses_count = statusesCount;
// Cache stats so embedded account objects in statuses can use them
// Cache stats (+ avatar URL) so embedded account objects in statuses can use them
cacheAccountStats(actorUrl, {
followersCount,
followingCount,
statusesCount,
createdAt: published || undefined,
avatarUrl: avatarUrl || undefined,
});
return account;