diff --git a/src/lib/__tests__/editor-html.test.ts b/src/lib/__tests__/editor-html.test.ts
index d5cac31a..e7373922 100644
--- a/src/lib/__tests__/editor-html.test.ts
+++ b/src/lib/__tests__/editor-html.test.ts
@@ -1,5 +1,7 @@
-import { describe, expect, it } from 'vitest';
-import { buildEditorCsp, buildEditorHtml, shouldBlockEditorRemoteImages } from '../editor-html';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ buildEditorCsp, buildEditorHtml, CHANGE_THROTTLE_MS, shouldBlockEditorRemoteImages,
+} from '../editor-html';
import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens';
function extractScript(html: string): string {
@@ -120,3 +122,77 @@ describe('shouldBlockEditorRemoteImages', () => {
expect(shouldBlockEditorRemoteImages({ ...opts, seedHtml: '
Hi
' })).toBe(false);
});
});
+
+// Runs the page script against a minimal fake DOM to check what it posts to
+// RN while the user types (PF8: each post re-renders the whole composer).
+describe('editor page messages', () => {
+ function boot() {
+ const listeners: Record void>> = {};
+ const docListeners: Record void>> = {};
+ const posted: Array<{ type: string; payload: unknown }> = [];
+ const editor = {
+ innerHTML: '',
+ get textContent() { return this.innerHTML.replace(/<[^>]*>/g, ''); },
+ scrollHeight: 100,
+ querySelector: () => null,
+ setAttribute: () => undefined,
+ focus: () => undefined,
+ addEventListener: (name: string, fn: () => void) => { (listeners[name] ??= []).push(fn); },
+ };
+ const commandState: Record = {};
+ const document = {
+ activeElement: editor as unknown,
+ getElementById: () => editor,
+ addEventListener: (name: string, fn: () => void) => { (docListeners[name] ??= []).push(fn); },
+ queryCommandState: (name: string) => !!commandState[name],
+ };
+ const window = {
+ ReactNativeWebView: { postMessage: (msg: string) => posted.push(JSON.parse(msg)) },
+ addEventListener: () => undefined,
+ getSelection: () => null,
+ };
+ const script = extractScript(buildEditorHtml({ initialHtml: '
', placeholder: '', c: LIGHT_COLORS }));
+ new Function('window', 'document', script)(window, document);
+ const changes = () => posted.filter((m) => m.type === 'change').map((m) => m.payload);
+ const selections = () => posted.filter((m) => m.type === 'selection');
+ const type = (html: string) => { editor.innerHTML = html; listeners.input.forEach((fn) => fn()); };
+ const fire = (name: string) => (listeners[name] ?? docListeners[name]).forEach((fn) => fn());
+ return { changes, selections, type, fire, commandState, posted };
+ }
+
+ beforeEach(() => { vi.useFakeTimers(); });
+ afterEach(() => { vi.useRealTimers(); });
+
+ it('posts the first keystroke at once and then at most one change per window', () => {
+ const page = boot();
+ const before = page.changes().length;
+ page.type('H
');
+ expect(page.changes().slice(before)).toEqual(['H
']);
+ page.type('He
');
+ page.type('Hel
');
+ expect(page.changes().slice(before)).toEqual(['H
']);
+ vi.advanceTimersByTime(CHANGE_THROTTLE_MS);
+ expect(page.changes().slice(before)).toEqual(['H
', 'Hel
']);
+ });
+
+ it('flushes a pending change on blur', () => {
+ const page = boot();
+ page.type('A
');
+ page.type('AB
');
+ page.fire('blur');
+ expect(page.changes().slice(-1)).toEqual(['AB
']);
+ vi.advanceTimersByTime(CHANGE_THROTTLE_MS * 2);
+ expect(page.changes().filter((c) => c === 'AB
')).toHaveLength(1);
+ });
+
+ it('posts the selection state only when the formatting changes', () => {
+ const page = boot();
+ page.fire('selectionchange');
+ page.fire('selectionchange');
+ expect(page.selections()).toHaveLength(1);
+ page.commandState.bold = true;
+ page.fire('selectionchange');
+ expect(page.selections()).toHaveLength(2);
+ expect((page.selections()[1].payload as { bold: boolean }).bold).toBe(true);
+ });
+});
diff --git a/src/lib/editor-html.ts b/src/lib/editor-html.ts
index f0e1ad4d..46db006e 100644
--- a/src/lib/editor-html.ts
+++ b/src/lib/editor-html.ts
@@ -15,6 +15,11 @@ import type { ThemePalette } from '../theme/tokens';
// user types, and the parent ScrollView handles overflow.
export const MIN_EDITOR_HEIGHT = 220;
+// Shortest gap between two `change` posts while typing (see the page script).
+// Readers that need the current body - send, save, format switch - ask the
+// page with `getHtml` instead of waiting for the next post.
+export const CHANGE_THROTTLE_MS = 400;
+
/**
* The editor page's Content-Security-Policy, mirroring the viewer's
* (`src/lib/email-html.ts`). Nothing but images, styles and our own inline
@@ -134,15 +139,18 @@ export function buildEditorHtml(opts: {
var editor = document.getElementById('editor');
var lastHtml = null;
var lastHeight = 0;
+ var lastSelection = '';
+ // Every 'change' re-renders the whole composer, so typing posts at most
+ // one per window: the first keystroke at once (the composer learns it is
+ // dirty), then the latest content when the window ends. Commands, blur
+ // and setHtml post straight away.
+ var CHANGE_THROTTLE_MS = ${CHANGE_THROTTLE_MS};
+ var lastChangeAt = 0;
+ var changeTimer = null;
+ // Checked on every keystroke, so it avoids serializing innerHTML.
function refreshEmpty() {
- var html = editor.innerHTML;
- var isEmpty =
- html === '' ||
- html === '
' ||
- html === '' ||
- html === '
' ||
- editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0;
+ var isEmpty = editor.textContent.trim() === '' && !editor.querySelector('img');
editor.setAttribute('data-empty', isEmpty ? 'true' : 'false');
}
@@ -160,13 +168,36 @@ export function buildEditorHtml(opts: {
}
function reportChange() {
+ if (changeTimer) {
+ clearTimeout(changeTimer);
+ changeTimer = null;
+ }
refreshEmpty();
var html = editor.innerHTML;
if (html === lastHtml) return;
lastHtml = html;
+ lastChangeAt = Date.now();
post('change', html);
}
+ function scheduleChange() {
+ refreshEmpty();
+ if (changeTimer) return;
+ var wait = CHANGE_THROTTLE_MS - (Date.now() - lastChangeAt);
+ if (wait <= 0) {
+ reportChange();
+ return;
+ }
+ changeTimer = setTimeout(function () {
+ changeTimer = null;
+ reportChange();
+ }, wait);
+ }
+
+ function flushChange() {
+ if (changeTimer) reportChange();
+ }
+
function reportSelection() {
function active(name) {
try { return document.queryCommandState(name); } catch (e) { return false; }
@@ -196,6 +227,11 @@ export function buildEditorHtml(opts: {
alignRight: active('justifyRight'),
link: inBlock('A'),
};
+ // The caret moves on every keystroke; only a change of the formatting
+ // state is worth a message (and a composer re-render).
+ var key = JSON.stringify(state);
+ if (key === lastSelection) return;
+ lastSelection = key;
post('selection', state);
}
@@ -206,10 +242,13 @@ export function buildEditorHtml(opts: {
lastHtml = editor.innerHTML;
editor.addEventListener('input', function () {
- reportChange();
+ scheduleChange();
reportHeight();
});
- editor.addEventListener('blur', function () { post('blur', null); });
+ editor.addEventListener('blur', function () {
+ flushChange();
+ post('blur', null);
+ });
editor.addEventListener('focus', function () { post('focus', null); });
document.addEventListener('selectionchange', function () {
if (document.activeElement === editor) reportSelection();
diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx
index bdaf094b..b8166204 100644
--- a/src/screens/ComposeScreen.tsx
+++ b/src/screens/ComposeScreen.tsx
@@ -1043,10 +1043,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
att: s.attachments.filter((a) => !a.inline).map((a) => a.blobId ?? a.localId),
});
- const currentSnapshot = snapshotOf({
+ // Memoized: the snapshot copies the whole body, and the screen re-renders
+ // for plenty that doesn't touch it (PF8).
+ const currentSnapshot = React.useMemo(() => snapshotOf({
to: finalTo, cc: finalCc, bcc: finalBcc, subject,
body: plainTextMode ? plainBody : bodyHtml, attachments,
- });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }), [finalTo, finalCc, finalBcc, subject, plainTextMode, plainBody, bodyHtml, attachments]);
const baselineRef = React.useRef(snapshotOf({
to: initialTo, cc: initialCc, bcc: initialBcc, subject: initialSubject,
body: plainTextMode ? initialPlainBody : initialBodyHtml, attachments: initialAttachments,
@@ -1322,7 +1325,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null;
if (sendingRef.current || !latestRef.current.needsSave) return;
- saveDraftRef.current({ live: false }).catch((err) => {
+ // Live: the editor posts its content throttled, so read the DOM itself.
+ saveDraftRef.current({ live: true }).catch((err) => {
console.warn('[compose] autosave failed', err);
});
}, wait);