Ignore:
Timestamp:
07/16/2026 12:20:51 PM (8 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
2d6a9c3
Parents:
72f936a
git-author:
Robin <roboburr@…> (07/12/2026 11:43:58 PM)
git-committer:
Robin <roboburr@…> (07/16/2026 12:20:51 PM)
Message:

Feature: followers list with delivery health for cleanup

Adds a /followers page (new Fediverse tab) listing remote accounts that
follow you, each with its last SUCCESSFUL delivery timestamp so dead accounts
surface for manual pruning. New ap_followers columns last_delivery_at /
last_error_at (additive migration) are stamped in deliverWithRetry and the
retry-queue worker on success / final give-up. Never-delivered and failing
accounts sort to the top and are highlighted; a Remove button drops the local
follower row after a manual check. i18n added for NL/EN/DE.

Co-Authored-By: Claude Opus 4.8 <noreply@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r72f936a r8878814  
    524524export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
    525525
     526// Followers with delivery health, for the management list. Never-delivered accounts
     527// first, then oldest successful delivery first — i.e. the cleanup candidates on top.
     528export function listFollowers(slug) {
     529  return db.prepare(
     530    `SELECT id, actor_uri, inbox, shared_inbox, created_at, last_delivery_at, last_error_at
     531     FROM ap_followers WHERE slug = ?
     532     ORDER BY (last_delivery_at IS NULL) DESC, last_delivery_at ASC, created_at ASC`
     533  ).all(slug);
     534}
     535// Manually drop a follower after a check (a still-live account would have to re-follow).
     536export function removeFollower(slug, id) {
     537  const info = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND id = ?').run(slug, id);
     538  return info.changes > 0;
     539}
     540
    526541// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
    527542let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
     
    724739  try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ }
    725740}
     741// Record delivery health per follower so the followers list can flag dead accounts.
     742// Keyed by inbox: a shared-inbox POST reaches every follower behind it, so all of them
     743// are marked. A non-follower inbox (inline @mention) simply matches 0 rows.
     744let _fDelivOk, _fDelivErr;
     745function markFollowerDelivery(slug, inbox, ok) {
     746  if (!slug || !inbox) return;
     747  try {
     748    if (!_fDelivOk) {
     749      _fDelivOk = db.prepare('UPDATE ap_followers SET last_delivery_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
     750      _fDelivErr = db.prepare('UPDATE ap_followers SET last_error_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
     751    }
     752    (ok ? _fDelivOk : _fDelivErr).run(slug, inbox, inbox);
     753  } catch { /* health tracking is non-fatal */ }
     754}
    726755// Deliver now; queue for retry if it fails.
    727756export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
    728757  if (!inbox) return;
    729   try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) return; } catch { /* queue below */ }
     758  try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) { markFollowerDelivery(slug, inbox, true); return; } } catch { /* queue below */ }
    730759  enqueueDelivery(slug, inbox, activity);
    731760}
     
    746775        ok = st >= 200 && st < 300;
    747776      } catch { ok = false; }
    748       if (ok) { deliveryStmts().del.run(row.id); continue; }
     777      if (ok) { markFollowerDelivery(row.slug, row.inbox, true); deliveryStmts().del.run(row.id); continue; }
    749778      const attempts = row.attempts + 1;
    750       if (attempts >= DELIVERY_MAX_ATTEMPTS) { deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
     779      if (attempts >= DELIVERY_MAX_ATTEMPTS) { markFollowerDelivery(row.slug, row.inbox, false); deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
    751780      // Index the backoff on the CURRENT attempt count (row.attempts) so the first
    752781      // retry uses the 1-min tier instead of skipping it.
     
    24342463  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
    24352464  getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
    2436   linkifyBody,
     2465  linkifyBody, listFollowers, removeFollower,
    24372466};
Note: See TracChangeset for help on using the changeset viewer.