feat: fold the quoted history of a reply behind a toggle in the composer (bulwarkmail/webmail#1405)

This commit is contained in:
Linus Rath
2026-10-31 03:57:26 +01:00
parent 482e9a2014
commit 9fd8099369
4 changed files with 248 additions and 3 deletions
+9 -1
View File
@@ -87,10 +87,17 @@ interface Props {
* stays visible (see `pinVisualViewport` in editor-html).
*/
onViewportShift?: (dy: number) => void;
/** Start a reply's quoted original hidden behind a toggle (read once, on mount). */
collapseQuote?: boolean;
/** The quote toggle's accessible names. */
quoteToggleLabels?: { show: string; hide: string };
}
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift },
{
initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift,
collapseQuote = false, quoteToggleLabels,
},
ref,
) {
const c = useColors();
@@ -113,6 +120,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
const html = React.useMemo(
() => buildEditorHtml({
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
collapseQuote, quoteToggleLabels,
}),
// c reference changes per theme switch but we don't want to lose user
// edits on a theme change. Bake in the initial colors only.
+117
View File
@@ -1,4 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import {
buildEditorCsp, buildEditorHtml, CHANGE_THROTTLE_MS, isEditorNavigationAllowed, shouldBlockEditorRemoteImages,
} from '../editor-html';
@@ -318,3 +320,118 @@ describe('editor visual viewport pinning', () => {
expect(() => boot(true, false)).not.toThrow();
});
});
describe('collapsed reply quote (webmail #1405)', () => {
type Listener = (e?: { preventDefault: () => void }) => void;
function boot(opts: { collapseQuote?: boolean; withQuote?: boolean } = {}) {
const docListeners: Record<string, Listener[]> = {};
const classes = new Set<string>();
const appended: unknown[] = [];
const created: string[] = [];
let anchorNode: unknown = null;
const editor: Record<string, unknown> = {
innerHTML: '',
get textContent() { return 'x'; },
scrollHeight: 400,
classList: {
add: (n: string) => classes.add(n),
remove: (n: string) => classes.delete(n),
contains: (n: string) => classes.has(n),
},
querySelector: () => null,
setAttribute: () => undefined,
focus: () => undefined,
addEventListener: () => undefined,
};
const quoteChild = { parentNode: null as unknown };
const quote = {
hasAttribute: (n: string) => n === 'data-quoted-html',
nextElementSibling: null,
offsetTop: 120,
offsetLeft: 16,
parentNode: editor,
};
quoteChild.parentNode = quote;
const typed = { hasAttribute: () => false, nextElementSibling: opts.withQuote === false ? null : quote, parentNode: editor };
Object.defineProperty(editor, 'firstElementChild', { get: () => typed });
const button = {
style: {} as Record<string, string>,
attrs: {} as Record<string, string>,
listeners: {} as Record<string, Listener[]>,
title: '',
setAttribute(n: string, v: string) { this.attrs[n] = v; },
addEventListener(n: string, fn: Listener) { (this.listeners[n] ??= []).push(fn); },
};
const document = {
activeElement: editor as unknown,
body: { appendChild: (n: unknown) => appended.push(n) },
getElementById: () => editor,
createElement: (tag: string) => { created.push(tag); return button; },
addEventListener: (name: string, fn: Listener) => { (docListeners[name] ??= []).push(fn); },
queryCommandState: () => false,
};
const window = {
ReactNativeWebView: { postMessage: () => undefined },
addEventListener: () => undefined,
getSelection: () => ({ rangeCount: 1, anchorNode }),
};
const script = extractScript(buildEditorHtml({
initialHtml: '<p><br></p><div data-quoted-html="true">q</div>',
placeholder: '',
c: LIGHT_COLORS,
collapseQuote: opts.collapseQuote ?? true,
quoteToggleLabels: { show: 'Zitat zeigen', hide: 'Zitat verbergen' },
}));
new Function('window', 'document', script)(window, document);
const click = () => (button.listeners.click ?? []).forEach((fn) => fn({ preventDefault: () => undefined }));
const caretAt = (node: unknown) => { anchorNode = node; (docListeners.selectionchange ?? []).forEach((fn) => fn()); };
return { classes, appended, created, button, click, caretAt, quoteChild, typed };
}
it('starts a reply with the quote folded behind a toggle outside the editable text', () => {
const page = boot();
expect(page.classes.has('rne-quote-collapsed')).toBe(true);
// On the page body, never inside the editor: it is no part of the message.
expect(page.appended).toEqual([page.button]);
expect(page.button.attrs['aria-expanded']).toBe('false');
expect(page.button.attrs['aria-label']).toBe('Zitat zeigen');
expect(page.button.style.top).toBe('124px');
expect(page.button.style.display).toBe('block');
});
it('shows and hides the quote on a tap', () => {
const page = boot();
page.click();
expect(page.classes.has('rne-quote-collapsed')).toBe(false);
expect(page.button.attrs['aria-label']).toBe('Zitat verbergen');
page.click();
expect(page.classes.has('rne-quote-collapsed')).toBe(true);
});
it('shows the quote when the caret moves into it', () => {
const page = boot();
page.caretAt(page.typed);
expect(page.classes.has('rne-quote-collapsed')).toBe(true);
page.caretAt(page.quoteChild);
expect(page.classes.has('rne-quote-collapsed')).toBe(false);
});
it('adds nothing to a message without a quote, or when not asked', () => {
expect(boot({ withQuote: false }).created).toEqual([]);
expect(boot({ collapseQuote: false }).created).toEqual([]);
});
it('hides the quote with styles on the editor element, so it stays in the sent body', () => {
const html = buildEditorHtml({ initialHtml: 'x', placeholder: '', c: LIGHT_COLORS, collapseQuote: true });
expect(html).toMatch(/#editor\.rne-quote-collapsed > \[data-quoted-html\] \{ height: 30px; overflow: hidden; \}/);
const script = extractScript(html);
expect(() => new Function(script)).not.toThrow();
expect(script).not.toContain('\\');
});
it('is asked for on a reply in the composer', () => {
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
expect(source).toMatch(/const collapseReplyQuote = isReplyLike && \(mode === 'reply' \|\| mode === 'replyAll'\);/);
expect(source).toMatch(/collapseQuote=\{collapseReplyQuote && editorSeedHtml === initialBodyHtml\}/);
});
});
+112 -2
View File
@@ -21,6 +21,9 @@ export const MIN_EDITOR_HEIGHT = 220;
// page with `getHtml` instead of waiting for the next post.
export const CHANGE_THROTTLE_MS = 400;
// Room above a reply's quote for its "•••" toggle (px).
const QUOTE_TOGGLE_SPACE = 30;
/**
* The editor page's Content-Security-Policy, mirroring the viewer's
* (`src/lib/email-html.ts`). Nothing but images, styles and our own inline
@@ -138,8 +141,18 @@ export function buildEditorHtml(opts: {
* only: its WebView pans the visual viewport to reveal the caret.
*/
pinVisualViewport?: boolean;
/**
* A reply: the quoted original (the top-level `data-quoted-html` block,
* attribution included) starts hidden behind a "•••" toggle (webmail
* reply-quote-collapse, #1405). Hiding is styling on the editor element
* only, so the quote stays in the body that is saved and sent.
*/
collapseQuote?: boolean;
/** The toggle's accessible names. */
quoteToggleLabels?: { show: string; hide: string };
}): string {
const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false } = opts;
const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false, collapseQuote = false } = opts;
const quoteLabels = opts.quoteToggleLabels ?? { show: 'Show quoted text', hide: 'Hide quoted text' };
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
// are safely embedded (no template literal collision with the script body,
// and no `</script` that ends the script, see jsonForScript).
@@ -199,6 +212,23 @@ export function buildEditorHtml(opts: {
}
#editor pre { padding: 8px 12px; overflow-x: auto; }
::selection { background: ${c.primary}33; }
body { position: relative; }
#editor.rne-quote-toggle > [data-quoted-html] { padding-top: ${QUOTE_TOGGLE_SPACE}px; }
#editor.rne-quote-collapsed > [data-quoted-html] { height: ${QUOTE_TOGGLE_SPACE}px; overflow: hidden; }
#rne-quote-toggle {
position: absolute;
display: none;
margin: 0;
padding: 0 12px;
height: 22px;
line-height: 22px;
border: none;
border-radius: 999px;
background: ${c.surfaceActive};
color: ${c.textMuted};
font-size: 11px;
letter-spacing: 2px;
}
</style>
</head>
<body>
@@ -230,6 +260,7 @@ export function buildEditorHtml(opts: {
}
function reportHeight() {
placeQuoteToggle();
var h = Math.max(${MIN_EDITOR_HEIGHT}, editor.scrollHeight + 8);
if (Math.abs(h - lastHeight) < 2) return;
lastHeight = h;
@@ -304,12 +335,88 @@ export function buildEditorHtml(opts: {
post('selection', state);
}
// ── Collapsed reply quote ──────────────────────────────────────────
// The toggle lives outside the editable element, over the top of the
// quote, so it never becomes part of the message.
var COLLAPSE_QUOTE = ${collapseQuote ? 'true' : 'false'};
var QUOTE_LABELS = ${jsonForScript(quoteLabels)};
var quoteToggle = null;
function topQuote() {
var node = editor.firstElementChild;
while (node) {
if (node.hasAttribute && node.hasAttribute('data-quoted-html')) return node;
node = node.nextElementSibling;
}
return null;
}
function quoteCollapsed() {
return !!quoteToggle && editor.classList.contains('rne-quote-collapsed');
}
function placeQuoteToggle() {
if (!quoteToggle) return;
var quote = topQuote();
if (!quote) {
quoteToggle.style.display = 'none';
editor.classList.remove('rne-quote-collapsed');
return;
}
quoteToggle.style.display = 'block';
quoteToggle.style.top = (quote.offsetTop + 4) + 'px';
quoteToggle.style.left = quote.offsetLeft + 'px';
}
function setQuoteCollapsed(collapsed) {
if (!quoteToggle) return;
if (collapsed) editor.classList.add('rne-quote-collapsed');
else editor.classList.remove('rne-quote-collapsed');
var label = collapsed ? QUOTE_LABELS.show : QUOTE_LABELS.hide;
quoteToggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
quoteToggle.setAttribute('aria-label', label);
quoteToggle.title = label;
reportHeight();
}
// The caret never stays inside hidden text: moving it there shows the quote.
function revealQuoteAtCaret() {
if (!quoteCollapsed()) return;
var quote = topQuote();
var sel = window.getSelection();
if (!quote || !sel || !sel.rangeCount) return;
var node = sel.anchorNode;
while (node && node !== editor) {
if (node === quote) {
setQuoteCollapsed(false);
return;
}
node = node.parentNode;
}
}
// ── Initial content ────────────────────────────────────────────────
var initial = ${initialJson};
if (initial && typeof initial === 'string') editor.innerHTML = initial;
refreshEmpty();
lastHtml = editor.innerHTML;
if (COLLAPSE_QUOTE && topQuote()) {
quoteToggle = document.createElement('button');
quoteToggle.id = 'rne-quote-toggle';
quoteToggle.type = 'button';
quoteToggle.textContent = '•••';
// Keep the caret and the keyboard where they are.
quoteToggle.addEventListener('mousedown', function (e) { e.preventDefault(); });
quoteToggle.addEventListener('click', function (e) {
e.preventDefault();
setQuoteCollapsed(!quoteCollapsed());
});
document.body.appendChild(quoteToggle);
editor.classList.add('rne-quote-toggle');
setQuoteCollapsed(true);
}
editor.addEventListener('input', function () {
scheduleChange();
reportHeight();
@@ -320,7 +427,10 @@ export function buildEditorHtml(opts: {
});
editor.addEventListener('focus', function () { post('focus', null); });
document.addEventListener('selectionchange', function () {
if (document.activeElement === editor) reportSelection();
if (document.activeElement === editor) {
revealQuoteAtCaret();
reportSelection();
}
});
// ── Bridge: receive commands from RN ───────────────────────────────
+10
View File
@@ -696,6 +696,11 @@ export default function ComposeScreen({ route, navigation }: Props) {
});
const editorRef = React.useRef<RichTextEditorHandle>(null);
const collapseReplyQuote = isReplyLike && (mode === 'reply' || mode === 'replyAll');
const quoteToggleLabels = React.useMemo(() => ({
show: t('email_viewer.show_quoted_text', 'Show quoted text'),
hide: t('email_viewer.hide_quoted_text', 'Hide quoted text'),
}), [t]);
// The label column grows with the system font size: at 1.3 "Betreff"
// wrapped into "Betref" / "f" in a fixed 56 dp column.
const { fontScale } = useWindowDimensions();
@@ -2842,6 +2847,11 @@ export default function ComposeScreen({ route, navigation }: Props) {
ref={editorRef}
initialHtml={editorSeedHtml}
blockRemoteImages={blockRemoteImages}
// A reply's quoted history starts folded behind "•••" and still
// goes out with the reply (webmail #1405). Only the editor built
// on open: one rebuilt by a switch from plain text shows it all.
collapseQuote={collapseReplyQuote && editorSeedHtml === initialBodyHtml}
quoteToggleLabels={quoteToggleLabels}
placeholder={t('email_composer.body_placeholder', 'Write your message...')}
onChange={setBodyHtml}
onSelectionChange={setSelState}