source: Klonkt/test/rich-reply.test.js

main
Last change on this file was e9c9ae1, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: mentions bar with conversation partners (klonkt-demo-u02)

Implicit mentions leave the text and become an editable "To:" bar above the
reply editor: the parent author plus the thread's ancestor authors as chips.
Removing a chip stops addressing that partner (no mention anchor, no Mention
tag, no inbox ping); explicit @mentions typed in the text keep working via the
existing resolveMentionsInText path.

  • getInteractions: each thread node gets "participants" (author + ancestor chain via the byId map, own nodes skipped, deduped, capped at 8) and carries actor_uri now.
  • reply-editor partial: the bar renders server-side with a hidden "mentions" JSON field carrying the full list, so the no-JS path addresses everyone; the JS removes chips and mirrors the remaining list into the field.
  • deliverReply({mentions}): undefined = legacy parent-only behavior; an array (possibly empty) = the kept list drives the mention prefix, the Mention tags (mentionTags reads the content anchors) and the per-actor inbox pings. to_actor/to_handle follow the kept list (parent when kept, else the first chip, else null -> the note addresses Public only).
  • deliverOutboxUpdate: an edit reuses the OLD content's leading mention anchors instead of rebuilding just to_actor, so co-mentions survive edits (legacy rows fall back as before).
  • Interact page passes the target author as the single chip. Full-screen mobile keeps the top bar above the mentions bar (flex order).

4 new tests (participant chains, multi-chip tags + to_actor, empty bar goes
Public-only, co-mentions survive edits); 97 green. Browser-verified: bar shows
@bob + @alice on a nested reply, removing @alice updates the hidden field, the
sent reply mentions and addresses only @bob; no console errors.

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

  • Property mode set to 100644
File size: 10.3 KB
Line 
1// Rich replies (klonkt-demo-c7f fase 2): deliverReply accepts editor HTML,
2// sanitizes it, injects the parent mention into the first paragraph, stores the
3// reply language, and buildReplyNote carries contentMap. In-memory DB; the
4// parent actor lives on an unresolvable host, so delivery just queues.
5
6import { test } from 'node:test';
7import assert from 'node:assert/strict';
8
9process.env.DATABASE_PATH = ':memory:';
10process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
11
12const dbMod = await import('../src/config/database.js');
13const db = dbMod.default;
14dbMod.initializeDatabase();
15const AP = await import('../src/services/ActivityPubService.js');
16
17db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
18 .run('u1', 'robin', 'r@test', 'x', 'god');
19db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
20db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, created_at, updated_at)
21 VALUES ('p1','s1','hallo','u1','Hallo','<p>x</p>','published',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)`).run();
22
23const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get('me');
24const parent = {
25 id: 1, post_id: 'p1', actor_uri: 'https://unresolvable.invalid/u/alice',
26 actor_url: 'https://unresolvable.invalid/@alice', actor_handle: '@alice@unresolvable.invalid',
27 object_uri: 'https://unresolvable.invalid/notes/1',
28};
29
30test('rich html is sanitized, mention lands in the first paragraph, language stored', async () => {
31 const r = await AP.deliverReply(site, {
32 postId: 'p1', postSlug: 'hallo', parent,
33 text: '', html: '<p>Dag <strong>Alice</strong>!</p><script>evil()</script>',
34 language: 'nl',
35 });
36 assert.ok(r && r.id, 'reply stored');
37 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
38 assert.match(row.content, /<strong>Alice<\/strong>/);
39 assert.doesNotMatch(row.content, /<script/i);
40 assert.match(row.content, /^<p><a [^>]*class="u-url mention"/); // mention in first <p>
41 assert.equal(row.language, 'nl');
42
43 const note = AP.buildReplyNote('https://klonkt.test', site, row);
44 assert.equal(note.type, 'Note');
45 assert.deepEqual(Object.keys(note.contentMap), ['nl']);
46 assert.equal(note.contentMap.nl, note.content);
47});
48
49test('rich html without a leading <p> gets the mention as its own paragraph', async () => {
50 const r = await AP.deliverReply(site, {
51 postId: 'p1', postSlug: 'hallo', parent,
52 text: '', html: '<blockquote>quote</blockquote>', language: '',
53 });
54 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
55 assert.match(row.content, /^<p><a .*<\/p><blockquote>/s);
56 assert.equal(row.language, null);
57 assert.equal(AP.buildReplyNote('https://klonkt.test', site, row).contentMap, undefined);
58});
59
60test('the plain-text path is unchanged (escaped, br for newlines)', async () => {
61 const r = await AP.deliverReply(site, {
62 postId: 'p1', postSlug: 'hallo', parent, text: 'plain <b>niet</b>\ntweede',
63 });
64 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
65 assert.match(row.content, /plain &lt;b&gt;niet&lt;\/b&gt;<br>tweede/);
66});
67
68test('empty rich html (only tags/whitespace) is rejected like empty text', async () => {
69 const r = await AP.deliverReply(site, {
70 postId: 'p1', postSlug: 'hallo', parent, text: '', html: '<p> </p><script>x()</script>',
71 });
72 assert.equal(r, null);
73});
74
75test('a bogus language code is dropped, not stored', async () => {
76 const r = await AP.deliverReply(site, {
77 postId: 'p1', postSlug: 'hallo', parent, text: '', html: '<p>taalcheck</p>', language: 'not a lang!',
78 });
79 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
80 assert.equal(row.language, null);
81});
82
83test('attachments: own /media/ urls stored, note carries typed absolute attachments', async () => {
84 const r = await AP.deliverReply(site, {
85 postId: 'p1', postSlug: 'hallo', parent, text: 'met media', html: '',
86 attachments: [
87 { url: '/media/reply-media/a.webp', mediaType: 'image/webp', name: 'foto' },
88 { url: '/media/reply-media/b.mp3', mediaType: 'audio/mpeg', name: 'liedje' },
89 { url: 'https://evil.example/x.png', mediaType: 'image/png', name: 'remote' }, // rejected: not ours
90 { url: '/media/reply-media/c.pdf', mediaType: 'application/pdf', name: 'doc' }, // rejected: type
91 ],
92 });
93 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
94 const stored = JSON.parse(row.attachments);
95 assert.equal(stored.length, 2);
96 assert.deepEqual(stored.map((a) => a.url), ['/media/reply-media/a.webp', '/media/reply-media/b.mp3']);
97
98 const note = AP.buildReplyNote('https://klonkt.test', site, row);
99 assert.equal(note.attachment.length, 2);
100 assert.deepEqual(note.attachment.map((a) => a.type), ['Image', 'Audio']);
101 assert.equal(note.attachment[0].url, 'https://klonkt.test/media/reply-media/a.webp');
102});
103
104test('a media-only reply (no text) is delivered', async () => {
105 const r = await AP.deliverReply(site, {
106 postId: 'p1', postSlug: 'hallo', parent, text: '', html: '',
107 attachments: [{ url: '/media/reply-media/solo.webp', mediaType: 'image/webp', name: '' }],
108 });
109 assert.ok(r && r.id);
110 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
111 assert.match(row.content, /^<p><a /); // just the mention paragraph
112 assert.equal(JSON.parse(row.attachments).length, 1);
113});
114
115test('only foreign/invalid attachments and no text -> rejected', async () => {
116 const r = await AP.deliverReply(site, {
117 postId: 'p1', postSlug: 'hallo', parent, text: '', html: '',
118 attachments: [{ url: 'https://evil.example/x.png', mediaType: 'image/png' }],
119 });
120 assert.equal(r, null);
121});
122
123test('rich edit: content replaced, language updated, attachments survive', async () => {
124 const r = await AP.deliverReply(site, {
125 postId: 'p1', postSlug: 'hallo', parent, text: 'origineel', html: '',
126 attachments: [{ url: '/media/reply-media/keep.webp', mediaType: 'image/webp', name: 'blijft' }],
127 language: 'nl',
128 });
129 const upd = await AP.deliverOutboxUpdate(site, r.id, '', { html: '<p>bewerkt met <em>nadruk</em></p>', language: 'en' });
130 assert.ok(upd && upd.ok);
131 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
132 assert.match(row.content, /bewerkt met <em>nadruk<\/em>/);
133 assert.match(row.content, /^<p><a [^>]*class="u-url mention"/); // mention re-attached inline
134 assert.equal(row.language, 'en');
135 assert.equal(JSON.parse(row.attachments)[0].url, '/media/reply-media/keep.webp');
136});
137
138test('plain edit path unchanged; bogus language keeps the old one', async () => {
139 const r = await AP.deliverReply(site, { postId: 'p1', postSlug: 'hallo', parent, text: 'plain start', language: 'nl' });
140 const upd = await AP.deliverOutboxUpdate(site, r.id, 'plain bewerkt', { language: '???' });
141 assert.ok(upd && upd.ok);
142 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
143 assert.match(row.content, /plain bewerkt/);
144 assert.equal(row.language, 'nl');
145});
146
147test('participants: node author plus ancestor chain, deduped, self skipped', () => {
148 db.prepare(`INSERT INTO ap_interactions (post_id, kind, actor_uri, actor_handle, actor_url, content, object_uri, parent_uri, visibility)
149 VALUES ('p1','reply','https://a.test/u/alice','@alice@a.test','https://a.test/@alice','<p>top</p>','https://a.test/n/1',NULL,'public')`).run();
150 db.prepare(`INSERT INTO ap_interactions (post_id, kind, actor_uri, actor_handle, actor_url, content, object_uri, parent_uri, visibility)
151 VALUES ('p1','reply','https://b.test/u/bob','@bob@b.test','https://b.test/@bob','<p>kind</p>','https://b.test/n/2','https://a.test/n/1','public')`).run();
152 const { thread } = AP.getInteractions('p1', 'https://klonkt.test', site);
153 const top = thread.find((n) => n.noteId === 'https://a.test/n/1');
154 const child = top.children.find((n) => n.noteId === 'https://b.test/n/2');
155 assert.deepEqual(top.participants.map((p) => p.uri), ['https://a.test/u/alice']);
156 assert.deepEqual(child.participants.map((p) => p.uri), ['https://b.test/u/bob', 'https://a.test/u/alice']);
157 assert.equal(child.participants[1].handle, '@alice@a.test');
158});
159
160test('kept mentions: prefix carries every chip, tags follow, to_actor = parent', async () => {
161 const r = await AP.deliverReply(site, {
162 postId: 'p1', postSlug: 'hallo', parent, text: 'hoi allebei',
163 mentions: [
164 { uri: parent.actor_uri, url: parent.actor_url, handle: parent.actor_handle },
165 { uri: 'https://b.test/u/bob', url: 'https://b.test/@bob', handle: '@bob@b.test' },
166 ],
167 });
168 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
169 assert.equal(row.to_actor, parent.actor_uri);
170 const note = AP.buildReplyNote('https://klonkt.test', site, row);
171 const mentionTags = note.tag.filter((t) => t.type === 'Mention');
172 assert.deepEqual(mentionTags.map((t) => t.href).sort(), [parent.actor_uri, 'https://b.test/u/bob'].sort());
173});
174
175test('parent removed from the bar: no mention prefix for it, note goes public-only', async () => {
176 const r = await AP.deliverReply(site, {
177 postId: 'p1', postSlug: 'hallo', parent, text: 'zonder ping', mentions: [],
178 });
179 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
180 assert.equal(row.to_actor, null);
181 assert.doesNotMatch(row.content, /u-url mention/);
182 const note = AP.buildReplyNote('https://klonkt.test', site, row);
183 assert.deepEqual(note.to, ['https://www.w3.org/ns/activitystreams#Public']);
184 assert.equal(note.tag.filter((t) => t.type === 'Mention').length, 0);
185});
186
187test('editing a multi-mention reply keeps every co-mention', async () => {
188 const r = await AP.deliverReply(site, {
189 postId: 'p1', postSlug: 'hallo', parent, text: 'multi',
190 mentions: [
191 { uri: parent.actor_uri, url: parent.actor_url, handle: parent.actor_handle },
192 { uri: 'https://b.test/u/bob', url: 'https://b.test/@bob', handle: '@bob@b.test' },
193 ],
194 });
195 const upd = await AP.deliverOutboxUpdate(site, r.id, '', { html: '<p>aangepast</p>' });
196 assert.ok(upd && upd.ok);
197 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
198 assert.match(row.content, /aangepast/);
199 const note = AP.buildReplyNote('https://klonkt.test', site, row);
200 assert.deepEqual(note.tag.filter((t) => t.type === 'Mention').map((t) => t.href).sort(),
201 [parent.actor_uri, 'https://b.test/u/bob'].sort());
202});
Note: See TracBrowser for help on using the repository browser.