Changeset bfa6fa1 in Klonkt


Ignore:
Timestamp:
07/31/2026 06:02:01 PM (6 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
72ec6a4
Parents:
0ca7e9a4
Message:

Account-verhuizingen slice 3: het wegwijsbord (movedTo + homepage-link)

Het sluitstuk van shaer-0j2's federatiekant. De oude Klonkt blijft
staan en ZEGT waar hij heen ging, op twee plekken.

Voor servers: het actor-document draagt movedTo zodra moved_to gezet is
(FEP-7628: een verhuisd account moet dat publiceren en geldt als
inactief). Dat is precies waarom de oude actor online blijft: wie de
Move-activity miste (server offline, latere bezoeker) leert de
verhuizing alsnog door ons op te halen. Een kolomwaarde die geen URL is
haalt de draad nooit.

Voor mensen: de homepage opent met een groot wegwijsbord boven de feed,
met de handle van het nieuwe adres als grote link. Boven de posts met
opzet: niemand hoort een dode tijdlijn door te scrollen om te ontdekken
dat we weg zijn.

En de verhuizing zelf vertelt het nu twee keer: naast de Move gaat er
een Update van de actor naar elke volger-inbox, gebouwd uit de VERSE
rij, zodat het bord ook staat als de Move onderweg verdwijnt (FEP-7628
SHOULD).

Changed files:
src/services/ActivityPubService.js

  • buildActor: movedTo bij een geldige moved_to
  • moveAccount: Update(actor) naast de Move, per inbox

src/routes/posts.js

  • home: movedTo + leesbaar label (actorDisplay) naar de view

src/views/pages/home.ejs

  • wegwijsbord boven de feed, typeof-guard voor deploy-races

src/assets/css/style.css

  • .moved-banner: accentrand, grote link, tekst breekt netjes af

src/services/i18n.js

  • phome.moved_lead/_hint in nl/en/de

test/move-actor.test.js

  • movedTo aan/uit/rommel; Update draagt het bord naast de Move
  • slice-2-test telt nu Moves apart (Update rijdt mee)

remarks: banner visueel gecontroleerd (mobiel en desktop) door het echte
templateblok met de echte CSS te renderen; suite 372 groen.

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

Files:
6 edited

Legend:

Unmodified
Added
Removed
  • src/assets/css/style.css

    r0ca7e9a4 rbfa6fa1  
    559559
    560560.empty { text-align: center; padding: 4rem 1rem; }
     561/* Moved account (FEP-7628 slice 3): the signpost above the feed. Loud on
     562   purpose — a visitor must not read a stale timeline thinking it is current. */
     563.moved-banner {
     564    text-align: center;
     565    margin: 1.5rem 0 2rem;
     566    padding: 1.75rem 1.25rem;
     567    border: 2px solid var(--accent, #e8b04b);
     568    border-radius: var(--radius, 10px);
     569    background: color-mix(in srgb, var(--accent, #e8b04b) 10%, var(--paper-2, var(--paper)));
     570}
     571.moved-banner-lead { margin: 0 0 .5rem; font-size: 1rem; opacity: .85; }
     572.moved-banner-link {
     573    display: inline-block;
     574    font-family: var(--font-display);
     575    font-size: clamp(1.5rem, 5vw, 2.4rem);
     576    font-weight: 700;
     577    line-height: 1.15;
     578    overflow-wrap: anywhere;
     579}
     580.moved-banner-hint { margin: .75rem 0 0; font-size: .85rem; color: var(--ink-soft); }
    561581.empty h1 { font-size: 2rem; }
    562582.empty p { color: var(--ink-muted); }
  • src/routes/posts.js

    r0ca7e9a4 rbfa6fa1  
    232232  recordPageview(site.id, req);
    233233
     234  // FEP-7628 slice 3: this account moved. A visitor who lands here deserves
     235  // the same signpost the fediverse gets — one big link to the new address.
     236  const movedTo = site.moved_to && /^https?:\/\//i.test(String(site.moved_to)) ? String(site.moved_to) : null;
    234237  renderPage(req, res, 'pages/home', {
    235238    pinnedPosts,
    236239    posts,
    237240    hasMore, nextOffset: offset + FEED_PAGE, moreBase,
     241    movedTo,
     242    movedToLabel: movedTo ? (ActivityPubService.actorDisplay(site.slug, movedTo).handle || movedTo) : null,
    238243    pageTitle: site.title,
    239244    socialDescr: site.description || site.tagline || '',
  • src/services/ActivityPubService.js

    r0ca7e9a4 rbfa6fa1  
    234234    }
    235235  } catch { /* skip malformed ap_aliases */ }
     236  // FEP-7628 slice 3: this account moved. The old actor stays online AS A
     237  // SIGNPOST — that is the whole point of keeping it: whoever missed the Move
     238  // activity (offline server, later visitor) still learns where we went by
     239  // fetching us. Per the FEP the moved actor "should be considered inactive",
     240  // and publishers should stop delivering here.
     241  if (site.moved_to && /^https?:\/\//i.test(String(site.moved_to))) actor.movedTo = String(site.moved_to);
    236242  // Profile links → PropertyValue rows: Mastodon/PeerTube/WordPress-ActivityPub render these as
    237243  // profile metadata (rel=me enables link-back verification). Additive; ignored by simpler receivers.
     
    38923898    to: [`${me}/followers`],
    38933899  };
     3900  // FEP-7628: after setting movedTo, notify the followers with an Update of
     3901  // the actor, so their servers hold the signpost even if the Move itself is
     3902  // lost. Built from the FRESH row: `site` still carries the pre-move values.
     3903  const movedSite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(site.slug) || { ...site, moved_to: target.id };
     3904  const update = {
     3905    '@context': AP_CONTEXT,
     3906    id: `${me}#update-${Date.now()}-${rid()}`,
     3907    type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
     3908    object: buildActor(base, movedSite),
     3909    published: new Date().toISOString(),
     3910  };
    38943911  const inboxes = [...new Set(fStmts().list.all(site.slug).map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
     3912  const send = deliverFn || deliverWithRetry;
    38953913  for (const inbox of inboxes) {
    3896     await (deliverFn || deliverWithRetry)(site.slug, inbox, move, `${me}#main-key`, keys.private_pem);
     3914    await send(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
     3915    await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem);
    38973916  }
    38983917  console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'naar', inboxes.length, 'inbox(en)');
  • src/services/i18n.js

    r0ca7e9a4 rbfa6fa1  
    912912    'vblk.back': 'Terug',
    913913    'vblk.to_home': 'Naar de hoofdpagina',
     914    'phome.moved_lead': 'Dit account is verhuisd. Je vindt me nu hier:',
     915    'phome.moved_hint': 'Volg je me al? Dan verhuist je server je waarschijnlijk vanzelf mee. Zo niet: volg het nieuwe adres.',
    914916    'phome.empty_title': 'Hier is het nog stil.',
    915917    'phome.empty_sub': 'Nog geen posts. Spannend.',
     
    18541856    'vblk.back': 'Back',
    18551857    'vblk.to_home': 'Go to the main page',
     1858    'phome.moved_lead': 'This account has moved. You can find me here now:',
     1859    'phome.moved_hint': 'Already following me? Your server most likely moves you along by itself. If not: follow the new address.',
    18561860    'phome.empty_title': "It's quiet here for now.",
    18571861    'phome.empty_sub': 'No posts yet. Exciting.',
     
    27962800    'vblk.back': 'Zurück',
    27972801    'vblk.to_home': 'Zur Hauptseite',
     2802    'phome.moved_lead': 'Dieses Konto ist umgezogen. Du findest mich jetzt hier:',
     2803    'phome.moved_hint': 'Folgst du mir schon? Dann zieht dein Server dich wahrscheinlich automatisch mit. Falls nicht: folge der neuen Adresse.',
    27982804    'phome.empty_title': 'Hier ist es noch still.',
    27992805    'phome.empty_sub': 'Noch keine Beiträge. Spannend.',
  • src/views/pages/home.ejs

    r0ca7e9a4 rbfa6fa1  
    1717    #pcms-main) so it survives HTMX swaps and is visible on every
    1818    non-admin page. %>
     19
     20<%# FEP-7628 slice 3: this account moved. The signpost comes FIRST, before
     21    any post: a visitor should not scroll a dead feed to find out we left.
     22    typeof-guard because an old route may render this template for one
     23    request right after a deploy (view cache fills on first hit). %>
     24<% if (typeof movedTo !== 'undefined' && movedTo) { %>
     25  <div class="container">
     26    <aside class="moved-banner">
     27      <p class="moved-banner-lead"><%= t('phome.moved_lead') %></p>
     28      <a class="moved-banner-link" href="<%= movedTo %>" rel="me noopener"><%= movedToLabel || movedTo %></a>
     29      <p class="moved-banner-hint"><%= t('phome.moved_hint') %></p>
     30    </aside>
     31  </div>
     32<% } %>
    1933
    2034<!-- ========== TIMELINE ========== -->
  • test/move-actor.test.js

    r0ca7e9a4 rbfa6fa1  
    163163  assert.equal(r.target, 'https://elders.example/users/nieuw');
    164164  assert.equal(db.prepare("SELECT moved_to FROM sites WHERE slug = 'radio'").get().moved_to, 'https://elders.example/users/nieuw');
    165   assert.equal(delivered.length, 2, 'both follower inboxes');
    166   for (const d of delivered) {
    167     assert.equal(d.activity.type, 'Move');
     165  // Slice 3 added the Update(actor) alongside it, so count the Moves.
     166  const moves = delivered.filter((d) => d.activity.type === 'Move');
     167  assert.equal(moves.length, 2, 'one Move per follower inbox');
     168  for (const d of moves) {
    168169    assert.equal(d.activity.object, ME_RADIO);
    169170    assert.equal(d.activity.target, 'https://elders.example/users/nieuw');
    170171  }
    171   assert.ok(delivered.some((d) => d.inbox === 'https://b.example/shared'), 'shared inbox preferred');
    172 });
     172  assert.ok(moves.some((d) => d.inbox === 'https://b.example/shared'), 'shared inbox preferred');
     173});
     174
     175// ── Slice 3: the signpost ──────────────────────────────────────────
     176// The old actor stays online and SAYS where it went (FEP-7628 movedTo), and
     177// the followers are told with an Update alongside the Move.
     178
     179test('a moved account carries movedTo on its actor; an unmoved one does not', () => {
     180  const fresh = db.prepare("SELECT * FROM sites WHERE slug = 'blog'").get();
     181  assert.equal('movedTo' in buildActor('https://test.example', fresh), false, 'no move, no signpost');
     182  const moved = { ...fresh, moved_to: 'https://elders.example/users/nieuw' };
     183  assert.equal(buildActor('https://test.example', moved).movedTo, 'https://elders.example/users/nieuw');
     184  // Junk in the column never reaches the wire.
     185  assert.equal('movedTo' in buildActor('https://test.example', { ...fresh, moved_to: 'niet-een-url' }), false);
     186});
     187
     188test('the move tells the followers twice: an Update carrying movedTo, and the Move', async () => {
     189  db.prepare("DELETE FROM ap_followers WHERE slug = 'radio'").run();
     190  db.prepare("UPDATE sites SET moved_to = NULL WHERE slug = 'radio'").run();
     191  db.prepare("INSERT INTO ap_followers (slug, actor_uri, inbox) VALUES ('radio', 'https://c.example/u/c', 'https://c.example/inbox')").run();
     192  const sent = [];
     193  const r = await moveAccount(radioSite(), 'https://elders.example/users/nieuw', {
     194    fetchActorFn: async () => ({ id: 'https://elders.example/users/nieuw', inbox: 'https://elders.example/inbox', alsoKnownAs: [ME_RADIO] }),
     195    deliverFn: async (slug, inbox, activity) => { sent.push(activity); },
     196  });
     197  assert.equal(r.ok, true);
     198  const update = sent.find((a) => a.type === 'Update');
     199  const move = sent.find((a) => a.type === 'Move');
     200  assert.ok(update, 'an Update is sent');
     201  assert.ok(move, 'the Move is still sent');
     202  assert.equal(update.object.movedTo, 'https://elders.example/users/nieuw', 'the Update carries the signpost');
     203  assert.equal(update.object.id, ME_RADIO, 'and it is OUR actor being updated');
     204});
Note: See TracChangeset for help on using the changeset viewer.