diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index b961d380..79cee3e4 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -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(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(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. diff --git a/src/lib/__tests__/editor-html.test.ts b/src/lib/__tests__/editor-html.test.ts index ab75c1aa..95378f2c 100644 --- a/src/lib/__tests__/editor-html.test.ts +++ b/src/lib/__tests__/editor-html.test.ts @@ -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 = {}; + const classes = new Set(); + const appended: unknown[] = []; + const created: string[] = []; + let anchorNode: unknown = null; + const editor: Record = { + 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, + attrs: {} as Record, + listeners: {} as Record, + 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: '


q
', + 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\}/); + }); +}); diff --git a/src/lib/editor-html.ts b/src/lib/editor-html.ts index 84dba7e6..70ca846a 100644 --- a/src/lib/editor-html.ts +++ b/src/lib/editor-html.ts @@ -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 ` [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; + } @@ -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 ─────────────────────────────── diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 5506b13d..aeb50e9a 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -696,6 +696,11 @@ export default function ComposeScreen({ route, navigation }: Props) { }); const editorRef = React.useRef(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}