Files
indiekit-endpoint-activitypub/lib/mastodon/helpers/enrich-accounts.js
svemagie 97a902bda1 feat: merge upstream v3.7.1–v3.7.5 into svemagie/main
All five 3.7.x releases published 2026-03-21 in one pass.

Changes from upstream:

- lib/lookup-helpers.js: lookupWithSecurity → async with signed→unsigned
  fallback (handles servers like tags.pub that return 400 on signed GETs)

- lib/mastodon/helpers/account-cache.js: add reverse lookup map
  (hashId → actorUrl) populated by cacheAccountStats(); export
  getActorUrlFromId() for follow/unfollow resolution

- lib/mastodon/helpers/enrich-accounts.js: NEW — enrichAccountStats()
  enriches embedded account objects in serialized statuses with real
  follower/following/post counts; Phanpy never calls /accounts/:id so
  counts were always 0 without this

- lib/mastodon/routes/timelines.js: call enrichAccountStats() after
  serialising home, public, and hashtag timelines

- lib/mastodon/routes/statuses.js: processStatusContent() linkifies bare
  URLs and converts @user@domain mentions to <a> links; extractMentions()
  builds mention list; date lookup now tries both .000Z and bare Z suffixes

- lib/mastodon/routes/stubs.js: /api/v1/domain_blocks now returns real
  blocked-server hostnames from ap_blocked_servers instead of []

- lib/mastodon/routes/accounts.js: /accounts/relationships computes
  domain_blocking using ap_blocked_servers; resolveActorUrl() falls back
  to getActorUrlFromId() cache for timeline-author resolution

- lib/controllers/federation-mgmt.js: fetch blocked servers, blocked
  accounts, and muted accounts in parallel; pass to template

- views/activitypub-federation-mgmt.njk: add Moderation section showing
  blocked servers, blocked accounts, and muted accounts

- package.json: bump version 3.6.8 → 3.7.5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 20:22:04 +01:00

98 lines
3.5 KiB
JavaScript

/**
* Enrich embedded account objects in serialized statuses with real
* follower/following/post counts from remote AP collections.
*
* Phanpy (and some other clients) never call /accounts/:id — they
* trust the account object embedded in each status. Without enrichment,
* these show 0/0/0 for all remote accounts.
*
* Uses the account stats cache to avoid redundant fetches. Only resolves
* unique authors with 0 counts that aren't already cached.
*/
import { getCachedAccountStats } from "./account-cache.js";
import { resolveRemoteAccount } from "./resolve-account.js";
/**
* Enrich account objects in a list of serialized statuses.
* Resolves unique authors in parallel (max 5 concurrent).
*
* @param {Array} statuses - Serialized Mastodon Status objects (mutated in place)
* @param {object} pluginOptions - Plugin options with federation context
* @param {string} baseUrl - Server base URL
*/
export async function enrichAccountStats(statuses, pluginOptions, baseUrl) {
if (!statuses?.length || !pluginOptions?.federation) return;
// Collect unique author URLs that need enrichment
const accountsToEnrich = new Map(); // url -> [account references]
for (const status of statuses) {
collectAccount(status.account, accountsToEnrich);
if (status.reblog?.account) {
collectAccount(status.reblog.account, accountsToEnrich);
}
}
if (accountsToEnrich.size === 0) return;
// Resolve in parallel with concurrency limit
const entries = [...accountsToEnrich.entries()];
const CONCURRENCY = 5;
for (let i = 0; i < entries.length; i += CONCURRENCY) {
const batch = entries.slice(i, i + CONCURRENCY);
await Promise.all(
batch.map(async ([url, accounts]) => {
try {
const resolved = await resolveRemoteAccount(url, pluginOptions, baseUrl);
if (resolved) {
for (const account of accounts) {
account.followers_count = resolved.followers_count;
account.following_count = resolved.following_count;
account.statuses_count = resolved.statuses_count;
if (resolved.created_at && account.created_at) {
account.created_at = resolved.created_at;
}
if (resolved.note) account.note = resolved.note;
if (resolved.fields?.length) account.fields = resolved.fields;
if (resolved.avatar && resolved.avatar !== account.avatar) {
account.avatar = resolved.avatar;
account.avatar_static = resolved.avatar;
}
if (resolved.header) {
account.header = resolved.header;
account.header_static = resolved.header;
}
}
}
} catch {
// Silently skip failed resolutions
}
}),
);
}
}
/**
* Collect an account reference for enrichment if it has 0 counts
* and isn't already cached.
*/
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
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;
if (cached.createdAt) account.created_at = cached.createdAt;
return;
}
// Queue for remote resolution
if (!map.has(account.url)) {
map.set(account.url, []);
}
map.get(account.url).push(account);
}