Android's WebView shrinks the editor page's visual viewport to the part of
the view that is on screen and pans it to reveal the caret while typing.
The editor is an auto-height WebView inside the composer's ScrollView, so
nothing ever resets that pan: scrolled back to the top, the body is still
drawn shifted up by the pan offset and its first lines sit hidden under the
header fields until a scroll to the very bottom clamps it away.
The page now undoes every pan as it happens (scrolling the page top into
view, since window.scrollTo only moves the layout viewport) and reports the
offset as a `viewportShift` message; the composer scrolls its ScrollView by
the same amount so the caret stays visible. Android only; iOS and the other
RichTextEditor hosts keep their behaviour (hosts without the callback still
get the reset).
Reproduced and verified on a Pixel 4 (Android 13, WebView 153): before,
visualViewport.offsetTop stayed at ~18 px at the top of the composer and
cut the first line; after, it stays 0 and the caret still follows typing
above the keyboard.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The editor page posted its full `innerHTML` on every input event and its
formatting state on every selection change, which fires on every
keystroke too. Each message re-rendered the whole composer, where a
`JSON.stringify` snapshot of the body ran on every render (PF8).
- typing now posts at most one `change` per 400 ms: the first keystroke
at once, so the composer knows it is dirty, then the latest content
when the window ends; toolbar commands and blur post straight away
- the formatting state is posted only when it changes
- the empty-placeholder check no longer serializes the body per
keystroke, and the dirty-tracking snapshot is memoized
- autosave reads the live editor DOM, like send, save-and-close and the
format switch already do, so the throttle can't make it save a
slightly older body
The body stays in state rather than a ref: the Send button and the
close guard render from it.
The composer's editor page had no Content-Security-Policy, so quoting a
message loaded every remote image and tracking pixel in it, even when
the viewer had shown that message with remote content blocked.
The editor page now carries a CSP that mirrors the viewer's: nothing
loads but our inline script, styles and images, and remote images and
fonts are blocked whenever the quoted original is one the viewer would
block (untrusted sender and a policy other than "allow"). A reopened
draft that carries a quote counts as untrusted. Inline and pasted
images (`data:`, `blob:`) keep working, and the sent HTML is unchanged.
The template literal in buildEditorHtml cooked the backslash escapes in
the insertLink scheme regex (\/ and \? became bare / and ?), so the page
received /^(https?:|mailto:|tel:|sms:|/|?|#)/i — an invalid regex. The
resulting SyntaxError killed the entire inline editor script: no change
messages ever reached RN, bodyHtml stayed empty, canSend never enabled
without an attachment, and mails sent with one had empty bodies.
Regressed in 29b06a3.
Rewrite the scheme check without backslash escapes and move the page
builder into src/lib/editor-html.ts so a unit test can assert the emitted
script parses and stays backslash-free.
Verified on a Pixel 7 API 34 emulator (release build): typing now posts
change messages and the plain-text body extraction sees the content.