Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 053bf51d2047068ccfdc7ac5a76e3c8275bba1b5)
+++ src/services/ActivityPubService.js	(revision 8240e808ac4b40aeea736b2c331462cff9c1a3e5)
@@ -22,4 +22,6 @@
 import HtmlSanitizerService from './HtmlSanitizerService.js';
 import AudioEmbedService from './AudioEmbedService.js';
+import Push from './PushService.js';
+import { getTenancy } from './SettingsService.js';
 
 const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
@@ -1262,4 +1264,22 @@
 }
 
+// ── Web push to the owner (docs/webpush-design.md, slice 3) ─────────
+// Fire-and-forget: a notification must never block or break inbox processing.
+function pushEvent(slug, event) {
+  try { Push.notifySite(slug, event).catch(() => {}); } catch { /* never throw */ }
+}
+// Hub-aware path prefix for a site's pages ('' in solo).
+function pushPrefix(slug) {
+  try { return getTenancy() === 'hub' ? `/user/${slug}` : ''; } catch { return ''; }
+}
+// Site slug, target URL and title for a post-scoped notification.
+function pushPostCtx(postId) {
+  try {
+    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);
+    if (!r) return null;
+    return { site: r.site, title: r.title || r.post, url: `${pushPrefix(r.site)}/${r.post}#fediverse` };
+  } catch { return null; }
+}
+
 // Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
 export async function handleInbox(req, slugParam) {
@@ -1323,4 +1343,5 @@
     fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);
     try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }
+    pushEvent(slug, { type: 'follow', title: 'Nieuwe volger', body: `${fi.name || fi.handle || 'Iemand'} volgt je nu`, url: `${pushPrefix(slug)}/connect` });
     const me = actorId(base, slug);
     const keys = getOrCreateKeys(slug);
@@ -1385,4 +1406,17 @@
       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));
       console.log('[AP] reply', actorUri, '→', tgt.post_id);
+      {
+        // Private (followers/direct) replies push as a DM ping WITHOUT content
+        // (the push service should never carry private text, design decision);
+        // public replies carry a short snippet.
+        const ctx = pushPostCtx(tgt.post_id);
+        const vis = noteVisibility(o);
+        const priv = vis === 'direct' || vis === 'followers';
+        const who = ai.name || ai.handle || 'Iemand';
+        if (ctx) {
+          if (priv) pushEvent(ctx.site, { type: 'dm', title: 'Privébericht', body: `Nieuw bericht van ${who}`, url: `${pushPrefix(ctx.site)}/messages` });
+          else pushEvent(ctx.site, { type: 'reply', title: `Reactie op "${ctx.title}"`, body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: ctx.url });
+        }
+      }
       return 202;
     }
@@ -1427,5 +1461,13 @@
             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)')
               .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null);
-            if (r.changes) console.log('[AP] mention', actorUri, '→', slug);
+            if (r.changes) {
+              console.log('[AP] mention', actorUri, '→', slug);
+              const vis = noteVisibility(o);
+              const priv = vis === 'direct' || vis === 'followers';
+              const who = ai.name || ai.handle || 'Iemand';
+              // Same privacy rule as replies: private mentions push without content.
+              if (priv) pushEvent(slug, { type: 'dm', title: 'Privébericht', body: `Nieuw bericht van ${who}`, url: `${pushPrefix(slug)}/messages` });
+              else pushEvent(slug, { type: 'reply', title: 'Vermelding', body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
+            }
           } catch { /* ignore */ }
         }
@@ -1482,4 +1524,12 @@
       iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act));
       console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
+      {
+        const ctx = pushPostCtx(pid);
+        const who = ai.name || ai.handle || 'Iemand';
+        if (ctx) {
+          if (type === 'Like') pushEvent(ctx.site, { type: 'like', title: 'Nieuwe waardering', body: `${who} waardeerde "${ctx.title}"`, url: ctx.url });
+          else pushEvent(ctx.site, { type: 'boost', title: 'Geboost', body: `${who} boostte "${ctx.title}"`, url: ctx.url });
+        }
+      }
     } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
       // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
