Changeset 55bba23 in Klonkt


Ignore:
Timestamp:
06/25/2026 09:13:53 AM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
4b5223f
Parents:
f9f3312
Message:

fediverse: federate pin order via Add/Remove activities (reliable, push-based)

The featured COLLECTION is pull-based and Mastodon caches it with sticky StatusPins, so
reordering never propagated. Mastodon federates pins via Add/Remove activities to the
featured collection (Add -> StatusPin.create, Remove -> destroy), processed immediately.
resyncFeaturedPins removes every pin then re-adds in rank-DESC order (rank 1 last =
newest = shown first) with gaps. /save fires it on any pin/unpin/reorder. Replaces the
unreliable deliverActorUpdate(featured-refetch) approach.

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

Location:
src
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/routes/posts.js

    rf9f3312 r55bba23  
    367367  }
    368368
    369   // Pin/unpin changed → push an actor Update so Mastodon re-fetches the featured
    370   // (pinned) collection promptly instead of waiting for its own actor refresh.
     369  // Pin/unpin/reorder → push Add/Remove activities so followers' instances update the
     370  // pinned order immediately (reliable, unlike re-fetching the cached featured collection).
    371371  if ((post.pinned || 0) !== parsePinnedRank(pinned)) {
    372     ActivityPubService.deliverActorUpdate(site).catch(() => { /* best-effort */ });
     372    const unpinned = (post.pinned || 0) > 0 && parsePinnedRank(pinned) === 0 ? [post.id] : [];
     373    ActivityPubService.resyncFeaturedPins(site, unpinned).catch(() => { /* best-effort */ });
    373374  }
    374375
  • src/services/ActivityPubService.js

    rf9f3312 r55bba23  
    706706  };
    707707  for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
     708}
     709
     710// Reliably set the pinned order on followers' instances via Add/Remove activities
     711// (how Mastodon itself federates pins) — pushed to the inbox + processed immediately,
     712// unlike the featured COLLECTION which Mastodon caches with sticky StatusPins.
     713// Mastodon's Add skips an already-pinned status, so we REMOVE every pin first, wait,
     714// then ADD in rank-DESCENDING order (rank 1 added LAST → newest StatusPin → shown first,
     715// because Mastodon displays pins newest-first). `alsoRemove` = ids to unpin too.
     716export async function resyncFeaturedPins(site, alsoRemove = []) {
     717  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     718  if (!base || !site || !site.slug) return;
     719  const followers = fStmts().list.all(site.slug);
     720  if (!followers.length) return;
     721  const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
     722  const keys = getOrCreateKeys(site.slug);
     723  const me = actorId(base, site.slug);
     724  const keyId = `${me}#main-key`;
     725  const featured = `${me}/featured`;
     726  const AS = 'https://www.w3.org/ns/activitystreams';
     727  const note = (id) => noteId(base, id);
     728  const pinned = db.prepare(
     729    `SELECT id FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
     730       AND pinned IS NOT NULL AND pinned > 0
     731     ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
     732  ).all(site.id);
     733  const removeIds = [...new Set([...pinned.map((p) => p.id), ...alsoRemove])];
     734  // 1. Remove every current pin so Mastodon can recreate them in order.
     735  for (const id of removeIds) {
     736    const rm = { '@context': AS, id: `${me}#rm-${id}-${Date.now()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
     737    for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ });
     738  }
     739  if (!pinned.length) { console.log('[AP] unpinned all featured for', site.slug); return; }
     740  await new Promise((r) => setTimeout(r, 5000)); // let the Removes land first
     741  // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at.
     742  for (const p of pinned) {
     743    const add = { '@context': AS, id: `${me}#add-${p.id}-${Date.now()}`, type: 'Add', actor: me, object: note(p.id), target: featured, to: [PUBLIC], cc: [`${me}/followers`] };
     744    for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ });
     745    await new Promise((r) => setTimeout(r, 2000));
     746  }
     747  console.log('[AP] resynced', pinned.length, 'featured pins for', site.slug);
    708748}
    709749
     
    10381078  getOrCreateKeys, apWants, sendAP, actorId, noteId,
    10391079  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFeatured,
    1040   followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate,
     1080  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
    10411081  getInteractions, getInteractionById, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
    10421082  listOutbox, deliverOutboxDelete,
Note: See TracChangeset for help on using the changeset viewer.