Ignore:
Timestamp:
07/22/2026 07:12:11 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
96c714f
Parents:
053bf51
git-author:
Robin <roboburr@…> (07/22/2026 07:09:32 AM)
git-committer:
Robin <roboburr@…> (07/22/2026 07:12:11 AM)
Message:

Feature: web push slice 3, real triggers from the S2S inbox

The owner now gets a push when something actually happens: new follower,
reply on a post, mention, like, boost, or a private message. All four
trigger points sit right after the existing inbox writes and are
fire-and-forget (pushEvent catches everything): a notification can never
block or break federation processing.

Privacy rule from the design doc: private (followers/direct) replies and
mentions push as a plain "Nieuw bericht van X" WITHOUT the message text,
so the browser push service never carries private content. Public replies
and mentions carry a short plain-text snippet; likes/boosts carry the post
title. URLs are hub-aware (/user/<slug> prefix when tenancy is hub).

Per-type preferences from slice 2 are honoured by notifySite (follow/reply
/dm on by default, like/boost off).

Changed files:
src/services/ActivityPubService.js

  • pushEvent/pushPrefix/pushPostCtx helpers above handleInbox
  • Follow: "Nieuwe volger" → /connect
  • reply: public snippet → post#fediverse; private → dm ping → /messages
  • mention: same split, "Vermelding" → /messages
  • Like/Announce: "Nieuwe waardering"/"Geboost" with the post title

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r053bf51 r8240e80  
    2222import HtmlSanitizerService from './HtmlSanitizerService.js';
    2323import AudioEmbedService from './AudioEmbedService.js';
     24import Push from './PushService.js';
     25import { getTenancy } from './SettingsService.js';
    2426
    2527const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
     
    12621264}
    12631265
     1266// ── Web push to the owner (docs/webpush-design.md, slice 3) ─────────
     1267// Fire-and-forget: a notification must never block or break inbox processing.
     1268function pushEvent(slug, event) {
     1269  try { Push.notifySite(slug, event).catch(() => {}); } catch { /* never throw */ }
     1270}
     1271// Hub-aware path prefix for a site's pages ('' in solo).
     1272function pushPrefix(slug) {
     1273  try { return getTenancy() === 'hub' ? `/user/${slug}` : ''; } catch { return ''; }
     1274}
     1275// Site slug, target URL and title for a post-scoped notification.
     1276function pushPostCtx(postId) {
     1277  try {
     1278    const r = db.prepare('SELECT p.slug AS post, p.title, s.slug AS site FROM posts p JOIN sites s ON s.id = p.site_id WHERE p.id = ?').get(postId);
     1279    if (!r) return null;
     1280    return { site: r.site, title: r.title || r.post, url: `${pushPrefix(r.site)}/${r.post}#fediverse` };
     1281  } catch { return null; }
     1282}
     1283
    12641284// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
    12651285export async function handleInbox(req, slugParam) {
     
    13231343    fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);
    13241344    try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }
     1345    pushEvent(slug, { type: 'follow', title: 'Nieuwe volger', body: `${fi.name || fi.handle || 'Iemand'} volgt je nu`, url: `${pushPrefix(slug)}/connect` });
    13251346    const me = actorId(base, slug);
    13261347    const keys = getOrCreateKeys(slug);
     
    13851406      iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri, noteVisibility(o));
    13861407      console.log('[AP] reply', actorUri, '→', tgt.post_id);
     1408      {
     1409        // Private (followers/direct) replies push as a DM ping WITHOUT content
     1410        // (the push service should never carry private text, design decision);
     1411        // public replies carry a short snippet.
     1412        const ctx = pushPostCtx(tgt.post_id);
     1413        const vis = noteVisibility(o);
     1414        const priv = vis === 'direct' || vis === 'followers';
     1415        const who = ai.name || ai.handle || 'Iemand';
     1416        if (ctx) {
     1417          if (priv) pushEvent(ctx.site, { type: 'dm', title: 'Privébericht', body: `Nieuw bericht van ${who}`, url: `${pushPrefix(ctx.site)}/messages` });
     1418          else pushEvent(ctx.site, { type: 'reply', title: `Reactie op "${ctx.title}"`, body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: ctx.url });
     1419        }
     1420      }
    13871421      return 202;
    13881422    }
     
    14271461            const r = db.prepare('INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
    14281462              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null);
    1429             if (r.changes) console.log('[AP] mention', actorUri, '→', slug);
     1463            if (r.changes) {
     1464              console.log('[AP] mention', actorUri, '→', slug);
     1465              const vis = noteVisibility(o);
     1466              const priv = vis === 'direct' || vis === 'followers';
     1467              const who = ai.name || ai.handle || 'Iemand';
     1468              // Same privacy rule as replies: private mentions push without content.
     1469              if (priv) pushEvent(slug, { type: 'dm', title: 'Privébericht', body: `Nieuw bericht van ${who}`, url: `${pushPrefix(slug)}/messages` });
     1470              else pushEvent(slug, { type: 'reply', title: 'Vermelding', body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
     1471            }
    14301472          } catch { /* ignore */ }
    14311473        }
     
    14821524      iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act));
    14831525      console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
     1526      {
     1527        const ctx = pushPostCtx(pid);
     1528        const who = ai.name || ai.handle || 'Iemand';
     1529        if (ctx) {
     1530          if (type === 'Like') pushEvent(ctx.site, { type: 'like', title: 'Nieuwe waardering', body: `${who} waardeerde "${ctx.title}"`, url: ctx.url });
     1531          else pushEvent(ctx.site, { type: 'boost', title: 'Geboost', body: `${who} boostte "${ctx.title}"`, url: ctx.url });
     1532        }
     1533      }
    14841534    } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
    14851535      // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
Note: See TracChangeset for help on using the changeset viewer.