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
+2 -2
View File
@@ -11,7 +11,7 @@ import { remoteActorId } from "./id-mapping.js";
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const MAX_ENTRIES = 500; const MAX_ENTRIES = 500;
// Map<actorUrl, { followersCount, followingCount, statusesCount, createdAt, cachedAt }> // Map<actorUrl, { followersCount, followingCount, statusesCount, createdAt, avatarUrl, cachedAt }>
const cache = new Map(); const cache = new Map();
// Reverse map: accountId (hash) → actorUrl // Reverse map: accountId (hash) → actorUrl
@@ -21,7 +21,7 @@ const idToUrl = new Map();
/** /**
* Store account stats in cache. * Store account stats in cache.
* @param {string} actorUrl - The actor's URL (cache key) * @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) { export function cacheAccountStats(actorUrl, stats) {
if (!actorUrl) return; if (!actorUrl) return;
+14 -5
View File
@@ -77,18 +77,27 @@ export async function enrichAccountStats(statuses, pluginOptions, baseUrl) {
*/ */
function collectAccount(account, map) { function collectAccount(account, map) {
if (!account?.url) return; 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); const cached = getCachedAccountStats(account.url);
if (cached) { if (cached) {
account.followers_count = cached.followersCount || 0; account.followers_count = cached.followersCount || account.followers_count || 0;
account.following_count = cached.followingCount || 0; account.following_count = cached.followingCount || account.following_count || 0;
account.statuses_count = cached.statusesCount || 0; account.statuses_count = cached.statusesCount || account.statuses_count || 0;
if (cached.createdAt) account.created_at = cached.createdAt; if (cached.createdAt) account.created_at = cached.createdAt;
if (cached.avatarUrl) {
account.avatar = cached.avatarUrl;
account.avatar_static = cached.avatarUrl;
}
return; 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 // Queue for remote resolution
if (!map.has(account.url)) { if (!map.has(account.url)) {
map.set(account.url, []); map.set(account.url, []);
+11 -5
View File
@@ -86,10 +86,15 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl) {
if (outbox?.totalItems != null) statusesCount = outbox.totalItems; if (outbox?.totalItems != null) statusesCount = outbox.totalItems;
} catch { /* ignore */ } } catch { /* ignore */ }
// Get published/created date // Get published/created date — normalize to UTC ISO so clients display it correctly.
const published = actor.published // Temporal.Instant.toString() preserves the original timezone offset;
? String(actor.published) // passing through new Date() converts to "YYYY-MM-DDTHH:mm:ss.sssZ".
: null; let published = null;
if (actor.published) {
try {
published = new Date(String(actor.published)).toISOString();
} catch { /* ignore unparseable dates */ }
}
// Profile fields from attachments // Profile fields from attachments
const fields = []; const fields = [];
@@ -124,12 +129,13 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl) {
account.following_count = followingCount; account.following_count = followingCount;
account.statuses_count = statusesCount; 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, { cacheAccountStats(actorUrl, {
followersCount, followersCount,
followingCount, followingCount,
statusesCount, statusesCount,
createdAt: published || undefined, createdAt: published || undefined,
avatarUrl: avatarUrl || undefined,
}); });
return account; return account;