mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +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).
|
* stays visible (see `pinVisualViewport` in editor-html).
|
||||||
*/
|
*/
|
||||||
onViewportShift?: (dy: number) => void;
|
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(
|
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,
|
ref,
|
||||||
) {
|
) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
@@ -113,6 +120,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
const html = React.useMemo(
|
const html = React.useMemo(
|
||||||
() => buildEditorHtml({
|
() => buildEditorHtml({
|
||||||
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
|
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
|
||||||
|
collapseQuote, quoteToggleLabels,
|
||||||
}),
|
}),
|
||||||
// c reference changes per theme switch but we don't want to lose user
|
// 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.
|
// 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
buildEditorCsp, buildEditorHtml, CHANGE_THROTTLE_MS, isEditorNavigationAllowed, shouldBlockEditorRemoteImages,
|
buildEditorCsp, buildEditorHtml, CHANGE_THROTTLE_MS, isEditorNavigationAllowed, shouldBlockEditorRemoteImages,
|
||||||
} from '../editor-html';
|
} from '../editor-html';
|
||||||
@@ -318,3 +320,118 @@ describe('editor visual viewport pinning', () => {
|
|||||||
expect(() => boot(true, false)).not.toThrow();
|
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.
|
// page with `getHtml` instead of waiting for the next post.
|
||||||
export const CHANGE_THROTTLE_MS = 400;
|
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
|
* The editor page's Content-Security-Policy, mirroring the viewer's
|
||||||
* (`src/lib/email-html.ts`). Nothing but images, styles and our own inline
|
* (`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.
|
* only: its WebView pans the visual viewport to reveal the caret.
|
||||||
*/
|
*/
|
||||||
pinVisualViewport?: boolean;
|
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 {
|
}): 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
|
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
||||||
// are safely embedded (no template literal collision with the script body,
|
// are safely embedded (no template literal collision with the script body,
|
||||||
// and no `</script` that ends the script, see jsonForScript).
|
// 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; }
|
#editor pre { padding: 8px 12px; overflow-x: auto; }
|
||||||
::selection { background: ${c.primary}33; }
|
::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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -230,6 +260,7 @@ export function buildEditorHtml(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reportHeight() {
|
function reportHeight() {
|
||||||
|
placeQuoteToggle();
|
||||||
var h = Math.max(${MIN_EDITOR_HEIGHT}, editor.scrollHeight + 8);
|
var h = Math.max(${MIN_EDITOR_HEIGHT}, editor.scrollHeight + 8);
|
||||||
if (Math.abs(h - lastHeight) < 2) return;
|
if (Math.abs(h - lastHeight) < 2) return;
|
||||||
lastHeight = h;
|
lastHeight = h;
|
||||||
@@ -304,12 +335,88 @@ export function buildEditorHtml(opts: {
|
|||||||
post('selection', state);
|
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 ────────────────────────────────────────────────
|
// ── Initial content ────────────────────────────────────────────────
|
||||||
var initial = ${initialJson};
|
var initial = ${initialJson};
|
||||||
if (initial && typeof initial === 'string') editor.innerHTML = initial;
|
if (initial && typeof initial === 'string') editor.innerHTML = initial;
|
||||||
refreshEmpty();
|
refreshEmpty();
|
||||||
lastHtml = editor.innerHTML;
|
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 () {
|
editor.addEventListener('input', function () {
|
||||||
scheduleChange();
|
scheduleChange();
|
||||||
reportHeight();
|
reportHeight();
|
||||||
@@ -320,7 +427,10 @@ export function buildEditorHtml(opts: {
|
|||||||
});
|
});
|
||||||
editor.addEventListener('focus', function () { post('focus', null); });
|
editor.addEventListener('focus', function () { post('focus', null); });
|
||||||
document.addEventListener('selectionchange', function () {
|
document.addEventListener('selectionchange', function () {
|
||||||
if (document.activeElement === editor) reportSelection();
|
if (document.activeElement === editor) {
|
||||||
|
revealQuoteAtCaret();
|
||||||
|
reportSelection();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Bridge: receive commands from RN ───────────────────────────────
|
// ── Bridge: receive commands from RN ───────────────────────────────
|
||||||
|
|||||||
@@ -696,6 +696,11 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const editorRef = React.useRef<RichTextEditorHandle>(null);
|
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"
|
// The label column grows with the system font size: at 1.3 "Betreff"
|
||||||
// wrapped into "Betref" / "f" in a fixed 56 dp column.
|
// wrapped into "Betref" / "f" in a fixed 56 dp column.
|
||||||
const { fontScale } = useWindowDimensions();
|
const { fontScale } = useWindowDimensions();
|
||||||
@@ -2842,6 +2847,11 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
initialHtml={editorSeedHtml}
|
initialHtml={editorSeedHtml}
|
||||||
blockRemoteImages={blockRemoteImages}
|
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...')}
|
placeholder={t('email_composer.body_placeholder', 'Write your message...')}
|
||||||
onChange={setBodyHtml}
|
onChange={setBodyHtml}
|
||||||
onSelectionChange={setSelState}
|
onSelectionChange={setSelState}
|
||||||
|
|||||||
Reference in New Issue
Block a user