source: Klonkt/test/rich-reply.test.js@ 33e1dbd

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

Feature: rich replies phase 1 — shared editor, mobile full-screen, language (klonkt-demo-c7f)

Replying to fediverse comments used bare textareas in four places. This adds
ONE shared, progressively-enhanced editor component and mounts it on the two
new-reply spots (inline thread reply in fedi-node, and authorize_interaction);
the edit forms and prutter follow with the media phase.

  • partials/reply-editor.ejs + assets/js/reply-editor.js + css: renders a plain textarea that works without JS; the JS upgrades it to a contenteditable with a small toolbar (bold/italic/link/list/quote) and a language select. Assets load once per render even when the partial repeats per comment.
  • Mobile (max-width 700px): focusing the editor opens a FULL-SCREEN compose overlay (top bar with cancel and send, scroll lock), the right pattern on phones. Two real-world fixes came out of browser verification: site CSS gives thread forms display:contents, which collapses the form box and breaks both flex and position:fixed (now overridden with !important); and the overlay sits at z-index 1200, above the bottom tab bar (1050) and sheets (1100).
  • Server: fedi-reply and authorize_interaction accept content (editor HTML) + language next to text. deliverReply sanitizes the HTML (HtmlSanitizerService), runs the same mention/hashtag/URL enrichment as the plain path, and places the parent mention inline in the first paragraph (own paragraph before block content, merged paragraph around bare inline text). Plain-text path unchanged (no-JS fallback).
  • ap_outbox.language (additive) -> contentMap on the outgoing Note.

5 new tests (sanitize, mention placement, plain path unchanged, empty-html
reject, bogus language dropped); 88 green. Live-verified in the browser:
desktop upgrade, mobile full-screen (enter/cancel/scroll-lock), and a real
submit landing in ap_outbox with markup + language intact.

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

  • Property mode set to 100644
File size: 3.7 KB
RevLine 
[33e1dbd]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});
Note: See TracBrowser for help on using the repository browser.