Index: test/help-request-timeline.test.js
===================================================================
--- test/help-request-timeline.test.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
+++ test/help-request-timeline.test.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -0,0 +1,70 @@
+// A 🛟 help request (FEP-633c 5.2.1) is a message, not a post: it belongs in
+// Berichten and the Guardian PWA, never in de Krant. It used to land in both,
+// because the timeline insert only asked "top-level post from someone I follow"
+// and never looked at who the note was addressed to.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = (await import('../src/services/ActivityPubService.js')).default;
+
+const PUB = 'https://www.w3.org/ns/activitystreams#Public';
+const WARD = 'https://ward.test/ap/users/kid';
+const note = (extra) => ({ id: 'https://ward.test/notes/1', type: 'Note', content: '<p>hoi</p>', ...extra });
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s1', 'guard', 'Guard', 'u1', 1);
+
+test('a public post from someone we follow belongs in the timeline', () => {
+  assert.equal(AP.belongsInTimeline(note({ to: [PUB], cc: [`${WARD}/followers`] })), true);
+});
+
+test('a followers-only post still belongs there: you follow them', () => {
+  assert.equal(AP.belongsInTimeline(note({ to: [`${WARD}/followers`] })), true);
+});
+
+test('a direct note does not, whoever sent it', () => {
+  const dm = note({ to: ['https://test.example/ap/users/guard'] });
+  assert.equal(AP.noteVisibility(dm), 'direct');
+  assert.equal(AP.belongsInTimeline(dm), false, 'a DM is a message, not a feed post');
+});
+
+test('a help request is direct by construction, so it is refused too', () => {
+  const help = note({ to: ['https://test.example/ap/users/guard'], 'shaer:helpRequest': true });
+  assert.equal(AP.belongsInTimeline(help), false);
+});
+
+test('a reply never belongs in the timeline either: it belongs to its thread', () => {
+  assert.equal(AP.belongsInTimeline(note({ to: [PUB], inReplyTo: 'https://x.test/notes/9' })), false);
+});
+
+test('the self-heal drops a help request that was already cached as a post', async () => {
+  // Two rows with an identical shape; only the mention marks one as a 🛟.
+  for (const id of ['https://ward.test/notes/help', 'https://ward.test/notes/post']) {
+    db.prepare('INSERT INTO ap_timeline (id, slug, author_uri, content) VALUES (?,?,?,?)').run(id, 'guard', WARD, '<p>x</p>');
+  }
+  db.prepare('INSERT INTO ap_mentions (slug, object_uri, actor_uri, content, help_request) VALUES (?,?,?,?,1)')
+    .run('guard', 'https://ward.test/notes/help', WARD, '<p>x</p>');
+  // A public mention from someone you follow IS a post and must survive.
+  db.prepare('INSERT INTO ap_mentions (slug, object_uri, actor_uri, content, help_request) VALUES (?,?,?,?,0)')
+    .run('guard', 'https://ward.test/notes/post', WARD, '<p>x</p>');
+
+  db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('selfheal_version', '0')").run();
+  await AP.selfHealTimeline();
+
+  const left = db.prepare('SELECT id FROM ap_timeline WHERE slug = ?').all('guard').map((r) => r.id);
+  assert.ok(!left.includes('https://ward.test/notes/help'), 'the 🛟 is gone from the Krant');
+  assert.ok(left.includes('https://ward.test/notes/post'), 'the ordinary mention stays');
+});
+
+test('and it is still there for Berichten and the Guardian PWA', () => {
+  const help = db.prepare('SELECT * FROM ap_mentions WHERE object_uri = ?').get('https://ward.test/notes/help');
+  assert.ok(help, 'the mention row is untouched');
+  assert.equal(help.help_request, 1);
+  assert.ok(AP.getNotifications('guard', 20).some((n) => n.type === 'mention'), 'it shows up in Berichten');
+});
Index: test/note-body-shared.test.js
===================================================================
--- test/note-body-shared.test.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
+++ test/note-body-shared.test.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -0,0 +1,88 @@
+// One post, one rendering. De Krant, Berichten and the Guardian PWA all run a
+// note through partials/note-body, so a quote card, a link preview, the media
+// and the custom emojis show up wherever the post turns up — not only in the
+// feed it happened to arrive in.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import path from 'path';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const { renderNoteBody } = await import('../src/middleware/render.js');
+const AP = (await import('../src/services/ActivityPubService.js')).default;
+
+const VIEWS = path.join(process.cwd(), 'src', 'views', 'partials');
+const EMOJI = JSON.stringify({ ':party:': 'https://cdn.test/party.png' });
+const MEDIA = JSON.stringify([{ url: 'https://cdn.test/capture.png', type: 'image/png' }]);
+const QUOTE = JSON.stringify({ url: 'https://q.test/notes/7', author: { name: 'Opie', handle: '@opie@q.test' }, content: '<p>het origineel</p>', media: [] });
+const EMBED = JSON.stringify({ url: 'https://video.test/watch?v=1', title: 'Een filmpje', provider: 'video.test', media: [{ url: 'https://video.test/thumb.jpg', type: 'image/jpeg' }] });
+
+test('the content renders with its custom emojis', () => {
+  const html = renderNoteBody({ content: '<p>hoi :party:</p>', emoji_json: EMOJI }, 'nl');
+  assert.match(html, /class="tl-content"/);
+  assert.match(html, /<img[^>]+class="emoji"[^>]+party\.png/, ':party: became an image, not a shortcode');
+});
+
+test('a quoted post renders as the quote card', () => {
+  const html = renderNoteBody({ content: '<p>kijk</p>', quote_json: QUOTE }, 'nl');
+  assert.match(html, /class="tl-quote"/);
+  assert.match(html, /het origineel/);
+  assert.match(html, /@opie@q\.test/);
+});
+
+test('an external link preview renders as that same card, with the title escaped', () => {
+  const html = renderNoteBody({ content: '<p>kijk</p>', embed_json: EMBED }, 'nl');
+  assert.match(html, /class="tl-quote"/, 'one card for both, only the origin differs');
+  assert.match(html, /Een filmpje/);
+  assert.ok(!/<iframe/i.test(html), 'a preview is a thumbnail, never an embedded player');
+});
+
+test('a quote wins over a link preview: only one card', () => {
+  const html = renderNoteBody({ content: '<p>x</p>', quote_json: QUOTE, embed_json: EMBED }, 'nl');
+  assert.equal((html.match(/class="tl-quote"/g) || []).length, 1);
+  assert.match(html, /het origineel/);
+  assert.ok(!html.includes('Een filmpje'));
+});
+
+test('media renders, and a sensitive note keeps its veil', () => {
+  const plain = renderNoteBody({ content: '<p>x</p>', media_json: MEDIA }, 'nl');
+  assert.match(plain, /class="tl-media-img"/);
+  const nsfw = renderNoteBody({ content: '<p>x</p>', media_json: MEDIA, nsfw: 1, cw: 'spoiler' }, 'nl');
+  assert.match(nsfw, /nsfw-media/, 'the veil survives outside de Krant too');
+});
+
+test('an empty note renders nothing at all', () => {
+  assert.equal(renderNoteBody({ content: '' }, 'nl'), '');
+  assert.equal(renderNoteBody(null, 'nl'), '');
+});
+
+test('de Krant and Berichten both go through the shared partial', () => {
+  for (const f of ['tl-item.ejs', 'msg-item.ejs']) {
+    const src = fs.readFileSync(path.join(VIEWS, f), 'utf8');
+    assert.match(src, /partials\/note-body/, `${f} must render a post through the shared partial`);
+    assert.ok(!/class="tl-media-img"/.test(src), `${f} must not carry its own copy of the media markup`);
+  }
+});
+
+test('Berichten receives the columns that partial needs', () => {
+  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s1', 'me', 'Me', 'u1', 1);
+  db.prepare(`INSERT INTO ap_mentions (slug, object_uri, actor_uri, actor_name, actor_handle, content, emoji_json, media_json, quote_json, help_request)
+              VALUES ('me','https://r.test/notes/1','https://r.test/u/a','Anna','@a@r.test','<p>hoi :party:</p>',?,?,?,1)`).run(EMOJI, MEDIA, QUOTE);
+  const m = AP.getNotifications('me', 20).find((n) => n.type === 'mention');
+  assert.ok(m);
+  assert.equal(m.emoji_json, EMOJI);
+  assert.equal(m.media_json, MEDIA);
+  assert.equal(m.quote_json, QUOTE);
+  assert.equal(m.help_request, 1, 'so Berichten can mark a 🛟 as one');
+  // The proof: the same object, handed to the same renderer, comes out whole.
+  const html = renderNoteBody(m, 'nl');
+  assert.match(html, /party\.png/);
+  assert.match(html, /class="tl-quote"/);
+  assert.match(html, /class="tl-media-img"/);
+});
