mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
feat: customizable reader quick actions and shrink-to-fit email scaling
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<QuickAction, string> = {
|
||||
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 (
|
||||
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
@@ -81,6 +123,34 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
|
||||
<View style={styles.row}>
|
||||
<MoveVertical size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.rowLabel}>Reader quick actions</Text>
|
||||
</View>
|
||||
<Text style={styles.rowDescription}>
|
||||
The three buttons shown between Prev/Next at the bottom of an open
|
||||
email. Defaults to Reply, Reply All and Forward.
|
||||
</Text>
|
||||
{bottomActions.map((action, index) => (
|
||||
<View key={index} style={styles.slotRow}>
|
||||
<Text style={styles.slotLabel}>Slot {index + 1}</Text>
|
||||
<Select
|
||||
value={action}
|
||||
onChange={(v) => setQuickActionSlot(index, v as QuickAction)}
|
||||
options={QUICK_ACTION_OPTIONS}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
{relocated.length > 0 && (
|
||||
<Text style={styles.rowDescription}>
|
||||
{relocated.map((a) => QUICK_ACTION_LABELS[a]).join(', ')}
|
||||
{relocated.length === 1 ? ' is' : ' are'} moved to the top toolbar so
|
||||
you can still use {relocated.length === 1 ? 'it' : 'them'}.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -90,5 +160,13 @@ function makeStyles(c: ThemePalette) {
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
rowLabel: { ...typography.bodyMedium, color: c.text },
|
||||
rowDescription: { ...typography.caption, color: c.mutedForeground },
|
||||
slotRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
slotLabel: { ...typography.body, color: c.text },
|
||||
});
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -593,7 +593,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
|
||||
<Menu size={20} color={c.textMuted} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>{currentMailbox?.name ?? 'Inbox'}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Pressable
|
||||
onPress={() => { void handleImport(); }}
|
||||
@@ -1075,7 +1075,7 @@ function makeStyles(c: ThemePalette) {
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: c.text, flex: 1 }, // text-lg font-semibold
|
||||
headerTitle: { ...typography.h3, color: c.text, flexShrink: 1 }, // text-lg font-semibold
|
||||
headerLogo: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
|
||||
@@ -18,7 +18,12 @@ import { CalendarInvitationBanner } from '../components/email/CalendarInvitation
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { MoveSheet } from '../components/MoveSheet';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import {
|
||||
useSettingsStore,
|
||||
normalizeBottomQuickActions,
|
||||
REPLY_QUICK_ACTIONS,
|
||||
type QuickAction,
|
||||
} from '../stores/settings-store';
|
||||
import { setEmailKeywords } from '../api/email';
|
||||
import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email-export';
|
||||
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||
@@ -90,6 +95,17 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const attachmentPosition = useSettingsStore((s) => s.attachmentPosition);
|
||||
const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay);
|
||||
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
|
||||
const bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions);
|
||||
const bottomActions = React.useMemo(
|
||||
() => normalizeBottomQuickActions(bottomQuickActionsRaw),
|
||||
[bottomQuickActionsRaw],
|
||||
);
|
||||
// Any reply-family action the user pulled out of the bottom bar is surfaced
|
||||
// in the top toolbar so it stays reachable.
|
||||
const relocatedActions = React.useMemo(
|
||||
() => REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a)),
|
||||
[bottomActions],
|
||||
);
|
||||
|
||||
const onPressAttachment = React.useCallback(
|
||||
async (blobId: string, name: string | undefined, type: string | undefined) => {
|
||||
@@ -312,6 +328,78 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
// Registry of every action that can live in the bottom quick-action bar (or
|
||||
// be relocated to the top toolbar). `icon` is a factory so the same action
|
||||
// can render at the toolbar (18px) and bottom-bar (20px) sizes.
|
||||
const quickActionRegistry: Record<
|
||||
QuickAction,
|
||||
{ label: string; icon: (size: number, color: string) => React.ReactNode; onPress: () => void; available: boolean }
|
||||
> = {
|
||||
reply: {
|
||||
label: 'Reply',
|
||||
icon: (s, col) => <Reply size={s} color={col} />,
|
||||
onPress: () => navigateCompose('reply'),
|
||||
available: true,
|
||||
},
|
||||
replyAll: {
|
||||
label: 'Reply All',
|
||||
icon: (s, col) => <ReplyAll size={s} color={col} />,
|
||||
onPress: () => navigateCompose('replyAll'),
|
||||
available: true,
|
||||
},
|
||||
forward: {
|
||||
label: 'Forward',
|
||||
icon: (s, col) => <Forward size={s} color={col} />,
|
||||
onPress: () => navigateCompose('forward'),
|
||||
available: true,
|
||||
},
|
||||
delete: {
|
||||
label: 'Delete',
|
||||
icon: (s, col) => <Trash2 size={s} color={col} />,
|
||||
onPress: onDelete,
|
||||
available: true,
|
||||
},
|
||||
archive: {
|
||||
label: 'Archive',
|
||||
icon: (s, col) => <Archive size={s} color={col} />,
|
||||
onPress: onArchive,
|
||||
available: !!archiveMailbox && currentMailboxId !== archiveMailbox?.id,
|
||||
},
|
||||
markUnread: {
|
||||
label: unread ? 'Read' : 'Unread',
|
||||
icon: (s, col) => (unread ? <MailOpen size={s} color={col} /> : <Mail size={s} color={col} />),
|
||||
onPress: onToggleUnread,
|
||||
available: true,
|
||||
},
|
||||
star: {
|
||||
label: starred ? 'Unstar' : 'Star',
|
||||
icon: (s, col) => (
|
||||
<Star size={s} color={starred ? c.starred : col} fill={starred ? c.starred : 'transparent'} />
|
||||
),
|
||||
onPress: onToggleStar,
|
||||
available: true,
|
||||
},
|
||||
move: {
|
||||
label: 'Move',
|
||||
icon: (s, col) => <FolderInput size={s} color={col} />,
|
||||
onPress: () => setMoveMenuOpen(true),
|
||||
available: mailboxes.length > 0,
|
||||
},
|
||||
spam: {
|
||||
label: isInJunk ? 'Not spam' : 'Spam',
|
||||
icon: (s, col) =>
|
||||
isInJunk ? <ShieldCheck size={s} color={c.success} /> : <ShieldAlert size={s} color={col} />,
|
||||
onPress: onToggleSpam,
|
||||
available: !!junkMailbox || isInJunk,
|
||||
},
|
||||
tag: {
|
||||
label: 'Tag',
|
||||
icon: (s, col) => <Tag size={s} color={col} />,
|
||||
onPress: () => setTagMenuOpen(true),
|
||||
available: keywordDefs.length > 0,
|
||||
},
|
||||
};
|
||||
|
||||
const subject = email?.subject ?? subjectParam ?? '(no subject)';
|
||||
const from = email?.from?.[0];
|
||||
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
|
||||
@@ -329,6 +417,17 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<View style={styles.toolbarActions}>
|
||||
{relocatedActions.map((id) => {
|
||||
const def = quickActionRegistry[id];
|
||||
return (
|
||||
<ToolbarButton
|
||||
key={id}
|
||||
icon={def.icon(18, c.textSecondary)}
|
||||
label={def.label}
|
||||
onPress={def.onPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<ToolbarButton
|
||||
icon={<Trash2 size={18} color={c.textSecondary} />}
|
||||
label="Delete"
|
||||
@@ -453,21 +552,18 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
onPress={prevEmail ? () => goToEmail(prevEmail) : undefined}
|
||||
disabled={!prevEmail}
|
||||
/>
|
||||
<BottomBarButton
|
||||
icon={<Reply size={20} color={c.textSecondary} />}
|
||||
label="Reply"
|
||||
onPress={() => navigateCompose('reply')}
|
||||
/>
|
||||
<BottomBarButton
|
||||
icon={<ReplyAll size={20} color={c.textSecondary} />}
|
||||
label="Reply All"
|
||||
onPress={() => navigateCompose('replyAll')}
|
||||
/>
|
||||
<BottomBarButton
|
||||
icon={<Forward size={20} color={c.textSecondary} />}
|
||||
label="Forward"
|
||||
onPress={() => navigateCompose('forward')}
|
||||
/>
|
||||
{bottomActions.map((id) => {
|
||||
const def = quickActionRegistry[id];
|
||||
return (
|
||||
<BottomBarButton
|
||||
key={id}
|
||||
icon={def.icon(20, c.textSecondary)}
|
||||
label={def.label}
|
||||
onPress={def.available ? def.onPress : undefined}
|
||||
disabled={!def.available}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<BottomBarButton
|
||||
icon={<ChevronRight size={20} color={c.textMuted} />}
|
||||
label="Next"
|
||||
|
||||
@@ -21,6 +21,55 @@ export type SwipeAction =
|
||||
| 'pin'
|
||||
| 'move';
|
||||
export type SwipeMode = 'instant' | 'reveal';
|
||||
// Actions that can be placed in the email reader's bottom quick-action bar.
|
||||
// The first three are the reply family (the default bar); any reply-family
|
||||
// action the user removes from the bar is relocated to the top toolbar so it
|
||||
// stays reachable.
|
||||
export type QuickAction =
|
||||
| 'reply'
|
||||
| 'replyAll'
|
||||
| 'forward'
|
||||
| 'delete'
|
||||
| 'archive'
|
||||
| 'markUnread'
|
||||
| 'star'
|
||||
| 'move'
|
||||
| 'spam'
|
||||
| 'tag';
|
||||
|
||||
export const REPLY_QUICK_ACTIONS: QuickAction[] = ['reply', 'replyAll', 'forward'];
|
||||
|
||||
export const ALL_QUICK_ACTIONS: QuickAction[] = [
|
||||
'reply',
|
||||
'replyAll',
|
||||
'forward',
|
||||
'delete',
|
||||
'archive',
|
||||
'markUnread',
|
||||
'star',
|
||||
'move',
|
||||
'spam',
|
||||
'tag',
|
||||
];
|
||||
|
||||
// The reader bottom bar always shows exactly three quick actions (between the
|
||||
// prev/next nav buttons). Coerce any persisted value into three unique, valid
|
||||
// ids, backfilling from the reply-family default when entries are missing.
|
||||
export function normalizeBottomQuickActions(value: unknown): QuickAction[] {
|
||||
const out: QuickAction[] = [];
|
||||
if (Array.isArray(value)) {
|
||||
for (const a of value) {
|
||||
if (ALL_QUICK_ACTIONS.includes(a as QuickAction) && !out.includes(a as QuickAction)) {
|
||||
out.push(a as QuickAction);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const d of REPLY_QUICK_ACTIONS) {
|
||||
if (out.length >= 3) break;
|
||||
if (!out.includes(d)) out.push(d);
|
||||
}
|
||||
return out.slice(0, 3);
|
||||
}
|
||||
export type ArchiveMode = 'single' | 'year' | 'month';
|
||||
export type CalendarView = 'month' | 'week' | 'day' | 'agenda';
|
||||
export type FirstDayOfWeek = 0 | 1;
|
||||
@@ -103,6 +152,10 @@ interface PersistedSettings {
|
||||
swipeRightAction: SwipeAction;
|
||||
swipeMode: SwipeMode;
|
||||
|
||||
// Email reader's bottom quick-action bar (3 slots). Defaults to the reply
|
||||
// family; reply-family actions removed from here move to the top toolbar.
|
||||
bottomQuickActions: QuickAction[];
|
||||
|
||||
// Archive
|
||||
archiveMode: ArchiveMode;
|
||||
|
||||
@@ -210,6 +263,8 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
swipeRightAction: 'read',
|
||||
swipeMode: 'instant',
|
||||
|
||||
bottomQuickActions: ['reply', 'replyAll', 'forward'],
|
||||
|
||||
archiveMode: 'single',
|
||||
|
||||
calendarDefaultView: 'month',
|
||||
|
||||
Reference in New Issue
Block a user