mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
feat: fold the quoted history of a reply behind a toggle in the composer (bulwarkmail/webmail#1405)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 ───────────────────────────────
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user