Feedback reply: light Markdown formatting (bold / bullets / heading)
Per Codex: a constrained Markdown-ish composer rather than contenteditable. * goodnews/markup.render_reply_html — escapes everything first, then introduces only a tiny whitelist (**bold**, - bullets, #/##/### headings, paragraphs, line breaks). No links, attributes, inline styles, or raw HTML passthrough. * feedback_replies.message_html column (+ live migration); replies store both the Markdown text and the rendered HTML. * email_send.send_feedback_reply now sends multipart text/plain + text/html (the sanitized render, wrapped in a trusted email template). * Frontend: textarea + a small toolbar (Bold / • List / H) that inserts Markdown; the reply thread renders the server-sanitized HTML. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -136,13 +136,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
// In-site reply (plain text, sent via SMTP; only stored on successful send).
|
||||
// In-site reply (Markdown-ish: textarea + toolbar; server renders sanitized HTML).
|
||||
let replyingId = $state(null);
|
||||
let replyText = $state('');
|
||||
let replyBusy = $state(false);
|
||||
let replyErr = $state('');
|
||||
let replyTextarea = $state(null);
|
||||
function openReply(f) { replyingId = f.id; replyText = ''; replyErr = ''; }
|
||||
function cancelReply() { replyingId = null; replyText = ''; replyErr = ''; }
|
||||
|
||||
// Wrap the current selection (e.g. **bold**), restoring focus + selection.
|
||||
function mdWrap(token) {
|
||||
const el = replyTextarea;
|
||||
if (!el) return;
|
||||
const s = el.selectionStart, e = el.selectionEnd;
|
||||
const sel = replyText.slice(s, e) || 'text';
|
||||
replyText = replyText.slice(0, s) + token + sel + token + replyText.slice(e);
|
||||
queueMicrotask(() => {
|
||||
el.focus();
|
||||
el.selectionStart = s + token.length;
|
||||
el.selectionEnd = s + token.length + sel.length;
|
||||
});
|
||||
}
|
||||
// Prefix each selected line (e.g. "- " or "## ").
|
||||
function mdPrefix(prefix) {
|
||||
const el = replyTextarea;
|
||||
if (!el) return;
|
||||
const s = el.selectionStart, e = el.selectionEnd;
|
||||
const start = replyText.lastIndexOf('\n', s - 1) + 1;
|
||||
let end = replyText.indexOf('\n', e);
|
||||
if (end === -1) end = replyText.length;
|
||||
const block = replyText.slice(start, end) || 'item';
|
||||
const prefixed = block.split('\n').map((ln) => prefix + ln).join('\n');
|
||||
replyText = replyText.slice(0, start) + prefixed + replyText.slice(end);
|
||||
queueMicrotask(() => el.focus());
|
||||
}
|
||||
async function sendReply(f) {
|
||||
const msg = replyText.trim();
|
||||
if (!msg || replyBusy) return;
|
||||
@@ -434,7 +462,12 @@
|
||||
{#each f.replies as r (r.id)}
|
||||
<div class="rep">
|
||||
<span class="rl">↪ Replied · {fwhen(r.sent_at)}</span>
|
||||
<p class="repmsg">{r.message}</p>
|
||||
{#if r.message_html}
|
||||
<!-- server-sanitized HTML (tiny Markdown subset) -->
|
||||
<div class="repmsg">{@html r.message_html}</div>
|
||||
{:else}
|
||||
<p class="repmsg">{r.message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -442,7 +475,12 @@
|
||||
|
||||
{#if replyingId === f.id}
|
||||
<div class="composer">
|
||||
<textarea bind:value={replyText} rows="4" placeholder="Write a reply…"></textarea>
|
||||
<div class="mdbar">
|
||||
<button type="button" class="mdbtn" title="Bold (**…**)" onclick={() => mdWrap('**')}><b>B</b></button>
|
||||
<button type="button" class="mdbtn" title="Bullet list" onclick={() => mdPrefix('- ')}>• List</button>
|
||||
<button type="button" class="mdbtn" title="Heading" onclick={() => mdPrefix('## ')}>H</button>
|
||||
</div>
|
||||
<textarea bind:this={replyTextarea} bind:value={replyText} rows="4" placeholder="Write a reply… **bold**, - bullets, ## heading"></textarea>
|
||||
{#if replyErr}<p class="cerr">{replyErr}</p>{/if}
|
||||
<div class="cbtns">
|
||||
<button class="csend" onclick={() => sendReply(f)} disabled={replyBusy || !replyText.trim()}>
|
||||
@@ -624,8 +662,19 @@
|
||||
.thread { margin: 10px 0 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.rep { border-left: 2px solid var(--accent-soft); padding: 2px 0 2px 12px; }
|
||||
.rep .rl { font-size: 0.72rem; color: var(--muted); }
|
||||
.rep .repmsg { margin: 2px 0 0; font-size: 0.9rem; color: var(--ink); white-space: pre-wrap; }
|
||||
.rep .repmsg { margin: 2px 0 0; font-size: 0.9rem; color: var(--ink); }
|
||||
.rep .repmsg :global(p) { margin: 0 0 6px; white-space: pre-wrap; }
|
||||
.rep .repmsg :global(ul) { margin: 4px 0; padding-left: 20px; }
|
||||
.rep .repmsg :global(h3),
|
||||
.rep .repmsg :global(h4),
|
||||
.rep .repmsg :global(h5) { margin: 6px 0 4px; font-size: 0.95rem; }
|
||||
.composer { margin: 10px 0 0; }
|
||||
.mdbar { display: flex; gap: 6px; margin-bottom: 6px; }
|
||||
.mdbtn {
|
||||
font: inherit; font-size: 0.78rem; background: var(--surface); border: 1px solid var(--line);
|
||||
color: var(--ink); border-radius: 7px; padding: 3px 9px; cursor: pointer; line-height: 1.4;
|
||||
}
|
||||
.mdbtn:hover { border-color: var(--accent); color: var(--accent-deep); }
|
||||
.composer textarea {
|
||||
width: 100%; box-sizing: border-box; font: inherit; font-size: 0.9rem;
|
||||
padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px;
|
||||
|
||||
Reference in New Issue
Block a user