From b6ecf073698480d1d2b1bebed976d1ce27f3fcb9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:21:00 +0200 Subject: [PATCH] feat: customizable reader quick actions and shrink-to-fit email scaling --- src/components/EmailBodyView.tsx | 46 +++++++- src/components/settings/LayoutSettings.tsx | 84 +++++++++++++- src/lib/email-html.ts | Bin 13829 -> 14023 bytes src/screens/EmailListScreen.tsx | 4 +- src/screens/EmailThreadScreen.tsx | 128 ++++++++++++++++++--- src/stores/settings-store.ts | 55 +++++++++ 6 files changed, 295 insertions(+), 22 deletions(-) diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index 72a3646d..2acc95fb 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -51,23 +51,58 @@ const HEIGHT_REPORTER = ` // body.scrollHeight on Android WebView is pinned to the container viewport // height, so we can't trust it. A dedicated wrapper gives a deterministic // bounding box. + var wrapperEl = null; function ensureWrapper() { var b = document.body; if (!b) return null; + if (wrapperEl && wrapperEl.isConnected) return wrapperEl; var wrap = document.getElementById('__rn_email_body_wrap__'); if (!wrap) { wrap = document.createElement('div'); wrap.id = '__rn_email_body_wrap__'; wrap.style.display = 'block'; wrap.style.width = '100%'; + wrap.style.transformOrigin = 'top left'; while (b.firstChild) wrap.appendChild(b.firstChild); b.appendChild(wrap); } + wrapperEl = wrap; return wrap; } + // The width available for the email content (body content-box). + function availWidth() { + var b = document.body; + var cs = window.getComputedStyle(b); + var padL = parseFloat(cs.paddingLeft) || 0; + var padR = parseFloat(cs.paddingRight) || 0; + return b.clientWidth - padL - padR; + } + // Shrink-to-fit: emails authored at a fixed width (e.g. 600px tables) overflow + // a phone viewport and force horizontal scrolling. Lay the content out at its + // natural width, then scale the whole block down so its widest element fits + // the screen — the way native mail clients do. No sideways scrolling. + function fitToWidth(w) { + if (!w) return; + var avail = availWidth(); + if (avail <= 0) return; + // Reset to full width so wrapping content reports its true overflow extent + // (the widest unbreakable element) rather than a previously-scaled box. + w.style.width = '100%'; + var content = w.scrollWidth; + if (content > avail + 1) { + var scale = avail / content; + w.style.width = content + 'px'; + w.style.transform = 'scale(' + scale + ')'; + } else { + w.style.transform = 'none'; + } + } function measure() { var w = ensureWrapper(); if (!w) return 0; + fitToWidth(w); + // getBoundingClientRect reflects the applied transform, so r.height is the + // already-scaled visual height of the content. var r = w.getBoundingClientRect(); var b = document.body; var cs = window.getComputedStyle(b); @@ -117,7 +152,16 @@ const HEIGHT_REPORTER = ` if (document.body) ro.observe(document.body); } catch (e) {} try { - var mo = new MutationObserver(report); + var mo = new MutationObserver(function (records) { + for (var i = 0; i < records.length; i++) { + // Ignore the style mutations fitToWidth() applies to the wrapper itself, + // otherwise scaling would re-trigger a measure and loop indefinitely. + var rec = records[i]; + if (rec.target === wrapperEl && rec.type === 'attributes') continue; + report(); + return; + } + }); if (document.body) mo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true }); } catch (e) {} try { diff --git a/src/components/settings/LayoutSettings.tsx b/src/components/settings/LayoutSettings.tsx index 59929e40..463f1dd8 100644 --- a/src/components/settings/LayoutSettings.tsx +++ b/src/components/settings/LayoutSettings.tsx @@ -1,10 +1,18 @@ import React, { useEffect } from 'react'; import { View, Text, StyleSheet } from 'react-native'; -import { ArrowRight, ArrowLeft } from 'lucide-react-native'; -import { SettingsSection, RadioGroup } from './settings-section'; +import { ArrowRight, ArrowLeft, MoveVertical } from 'lucide-react-native'; +import { SettingsSection, RadioGroup, Select } from './settings-section'; import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { useSettingsStore, type SwipeAction, type SwipeMode } from '../../stores/settings-store'; +import { + useSettingsStore, + normalizeBottomQuickActions, + REPLY_QUICK_ACTIONS, + ALL_QUICK_ACTIONS, + type SwipeAction, + type SwipeMode, + type QuickAction, +} from '../../stores/settings-store'; const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [ { value: 'none', label: 'None' }, @@ -22,6 +30,24 @@ const SWIPE_MODE_OPTIONS: { value: SwipeMode; label: string }[] = [ { value: 'reveal', label: 'Reveal (swipe, then tap)' }, ]; +const QUICK_ACTION_LABELS: Record = { + reply: 'Reply', + replyAll: 'Reply All', + forward: 'Forward', + delete: 'Delete', + archive: 'Archive', + markUnread: 'Mark Read/Unread', + star: 'Star', + move: 'Move to folder', + spam: 'Spam', + tag: 'Tag', +}; + +const QUICK_ACTION_OPTIONS = ALL_QUICK_ACTIONS.map((value) => ({ + value, + label: QUICK_ACTION_LABELS[value], +})); + export function LayoutSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -31,11 +57,27 @@ export function LayoutSettings() { const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction); const swipeMode = useSettingsStore((s) => s.swipeMode); const setSwipeMode = useSettingsStore((s) => s.setSwipeMode); + const bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions); + const update = useSettingsStore((s) => s.updateSetting); const hydrated = useSettingsStore((s) => s.hydrated); const hydrate = useSettingsStore((s) => s.hydrate); useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); + const bottomActions = normalizeBottomQuickActions(bottomQuickActionsRaw); + // Pick an action for a bar slot. If the chosen action already occupies + // another slot, swap the two so the three slots stay unique. + const setQuickActionSlot = (index: number, value: QuickAction) => { + const next = [...bottomActions]; + const existing = next.indexOf(value); + if (existing !== -1 && existing !== index) { + next[existing] = next[index]; + } + next[index] = value; + update('bottomQuickActions', next); + }; + const relocated = REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a)); + return ( @@ -81,6 +123,34 @@ export function LayoutSettings() { /> + + + + Reader quick actions + + + The three buttons shown between Prev/Next at the bottom of an open + email. Defaults to Reply, Reply All and Forward. + + {bottomActions.map((action, index) => ( + + Slot {index + 1} +