Ignore:
Timestamp:
07/19/2026 05:08:32 PM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
feced2c
Parents:
2d66d66
git-author:
Robin <roboburr@…> (07/19/2026 05:08:12 PM)
git-committer:
Robin <roboburr@…> (07/19/2026 05:08:32 PM)
Message:

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@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r2d66d66 r33e1dbd  
    233233      inReplyTo: post.in_reply_to || undefined,
    234234      content: post.content,
     235      // Reply language (rich replies): the AS2 language map next to `content`.
     236      contentMap: post.language ? { [post.language]: post.content } : undefined,
    235237      url: post.post_slug ? `${base}/${encodeURIComponent(post.post_slug)}` : undefined,
    236238      published: toISO(post.created_at),
     
    710712    _listI = db.prepare('SELECT id, kind, object_uri, parent_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at, acted_boost, acted_like, visibility FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
    711713    _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
    712     _insO = db.prepare('INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, created_at) VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
     714    _insO = db.prepare('INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, created_at) VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
    713715    _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
    714716    _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
     
    18541856// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
    18551857// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
    1856 export async function deliverReply(site, { postId, postSlug, parent, text }) {
     1858export async function deliverReply(site, { postId, postSlug, parent, text, html, language }) {
    18571859  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    1858   if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
     1860  // Rich replies: `html` is the reply editor's HTML (sanitized here); `text` is
     1861  // the plain-text fallback (no-JS path, C2S `source`). Either may carry the reply.
     1862  const richClean = html ? HtmlSanitizerService.sanitize(String(html)) : '';
     1863  const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
     1864  if (!base || !site || !site.slug || !parent || (!String(text || '').trim() && !rich)) return null;
    18591865  const me = actorId(base, site.slug);
    18601866  const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
    18611867  const dispHandle = handle && handle[0] === '@' ? handle : '@' + (handle || '');
    1862   const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
    1863   const mres = await resolveMentionsInText(base, body); // link inline @mentions + collect their inboxes
    18641868  const mention = parent.actor_uri
    18651869    ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention" data-actor="${escHtml(parent.actor_uri)}">${escHtml(dispHandle)}</a> ` : '';
    1866   const content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
     1870  let content;
     1871  let mres;
     1872  if (rich) {
     1873    // Same enrichment pipeline as the plain path (mentions/hashtags/URLs), on
     1874    // sanitized editor HTML. The parent mention goes inline into the first
     1875    // paragraph (Mastodon convention), or becomes its own leading one.
     1876    mres = await resolveMentionsInText(base, rich);
     1877    const processed = linkUrls(linkHashtags(base, mres.html));
     1878    if (processed.startsWith('<p>')) {
     1879      content = processed.replace('<p>', `<p>${mention}`);            // inline in the first paragraph
     1880    } else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) {
     1881      content = `<p>${mention}</p>${processed}`;                      // block content: own leading paragraph
     1882    } else {
     1883      content = `<p>${mention}${processed}</p>`;                      // bare inline text: one paragraph together
     1884    }
     1885  } else {
     1886    const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
     1887    mres = await resolveMentionsInText(base, body); // link inline @mentions + collect their inboxes
     1888    content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
     1889  }
     1890  const replyLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
    18671891  // Dedup: skip if the exact same reply was already sent (double-submit guard).
    18681892  const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? LIMIT 1')
     
    18701894  if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
    18711895  const id = crypto.randomUUID();
    1872   iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
     1896  iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content, replyLang);
    18731897  const row = iStmts().getO.get(id);
    18741898  const note = buildReplyNote(base, site, row);
Note: See TracChangeset for help on using the changeset viewer.