Compare commits

...
70 Commits
Author SHA1 Message Date
Linus Rath 6aaa479875 chore: update version number to 0.1.76 2026-10-15 08:32:45 +02:00
Linus Rath 124c0258ad docs: add the final report to the native ui and ux audit 2026-10-15 04:19:41 +02:00
Linus Rath ccffd6b10d docs: tick the latest audit findings and note the deferred ones 2026-10-15 04:09:03 +02:00
Linus Rath 0d96d8d044 fix: fit every selection action and the count in the mail list header on a phone 2026-10-15 04:08:33 +02:00
Linus Rath 95c31631fb fix: show event titles instead of bare start times in a narrow month view 2026-10-15 04:05:55 +02:00
Linus Rath cf7275c9c3 fix: write calendar dates in the order of the app language 2026-10-15 04:05:55 +02:00
Linus Rath e452f72a8a fix: keep the all-day label in the day and week grid on one line 2026-10-15 04:05:49 +02:00
Linus Rath 776447dd41 fix: give sheet close buttons, chips and small text buttons a full-size touch target 2026-10-15 04:05:40 +02:00
Linus Rath 52eb18dc33 fix: show unified inbox rows with the list's text size, density and preview settings 2026-10-15 03:37:58 +02:00
Linus Rath 33dc3cd0dc fix: move through the contact name fields with the return key 2026-10-15 03:37:58 +02:00
Linus Rath 72b4f4abac fix: keep the edits when the recurring event question is cancelled 2026-10-15 03:37:58 +02:00
Linus Rath de1b955bfd docs: tick more fixed findings in the ux audit 2026-10-15 03:30:37 +02:00
Linus Rath c8434b0610 fix: make small buttons easier to hit 2026-10-15 03:30:00 +02:00
Linus Rath e19229a5c6 fix: keep screens clear of the camera cutout and side navigation bar in landscape 2026-10-15 03:29:59 +02:00
Linus Rath d7cee7ca78 fix: label the star button Unstar on a starred message 2026-10-15 03:29:27 +02:00
Linus Rath b59d00f0b7 fix: let the tag sheet scroll 2026-10-15 03:29:26 +02:00
Linus Rath 975ed08c82 fix: keep every thread toolbar button on screen, icon-only in landscape 2026-10-15 03:29:26 +02:00
Linus Rath 76170648f5 fix: open a long conversation at its first unread message 2026-10-15 03:29:25 +02:00
Linus Rath 509583e729 fix: widen the composer's field labels with the font size 2026-10-15 03:21:18 +02:00
Linus Rath 7b93265b4f fix: stop text fields cutting off their text at a larger font size 2026-10-15 03:21:18 +02:00
Linus Rath b5c4d62bde fix: put the mail search in the header row on a phone in landscape 2026-10-15 03:20:48 +02:00
Linus Rath c6cb44db61 fix: apply the text filters once typing pauses instead of querying per keystroke 2026-10-15 03:20:48 +02:00
Linus Rath 0db4bf00aa fix: say when a swipe, bulk action or import fails instead of failing silently 2026-10-15 03:20:48 +02:00
Linus Rath e94b782f8e fix: keep mail lines a readable length on wide screens 2026-10-15 03:18:58 +02:00
Linus Rath ae1ef5ba5e fix: move from username to password to code with the return key when signing in 2026-10-15 03:18:24 +02:00
Linus Rath f891bf3abb fix: announce buttons as buttons with their disabled and busy state 2026-10-15 03:17:08 +02:00
Linus Rath af55e0acef fix: name every format button for screen readers 2026-10-15 03:17:07 +02:00
Linus Rath 1ebc5c66f7 fix: ask about unsaved contact changes on every way out of the form 2026-10-15 03:16:46 +02:00
Linus Rath 0b3710265b fix: keep the address book chips whole in landscape 2026-10-15 03:16:46 +02:00
Linus Rath 56e1593646 fix: move from recipients to subject to body with the return key 2026-10-15 03:00:06 +02:00
Linus Rath e7857ef806 fix: close an untouched reply without asking to save it 2026-10-15 03:00:05 +02:00
Linus Rath b3290c3202 fix: use the webmail's dark theme colours: white primary on neutral greys 2026-10-15 02:59:45 +02:00
Linus Rath f231d1c5c6 docs: tick the fixed findings in the ux audit 2026-10-15 02:54:38 +02:00
Linus Rath 984c359bb4 fix: end the unified inbox and scheduled lists above the gesture bar 2026-10-15 02:53:07 +02:00
Linus Rath 50ab7145c4 fix: keep the compose button clear of the undo bar 2026-10-15 02:53:07 +02:00
Linus Rath 8f68d24bdb fix: make the filter warnings readable on the light theme 2026-10-15 02:51:56 +02:00
Linus Rath f22e851c82 fix: give the vacation subject the full width instead of squeezing its label 2026-10-15 02:51:55 +02:00
Linus Rath d4982f6932 fix: ask before removing someone's access to a shared file 2026-10-15 02:51:55 +02:00
Linus Rath 275d674577 fix: ask before deleting a tag 2026-10-15 02:51:54 +02:00
Linus Rath 0460da51e5 fix: ask before deleting an event, a task or a calendar feed 2026-10-15 02:51:54 +02:00
Linus Rath 90170ea0e7 fix: show one signature separator when the signature brings its own 2026-10-15 02:36:11 +02:00
Linus Rath cbc41c963b fix: fit the files header under the status bar and clear of landscape insets 2026-10-15 02:36:00 +02:00
Linus Rath 63eefd7700 fix: show file dates in the app's language and time format 2026-10-15 02:36:00 +02:00
Linus Rath d97571cf0f fix: open folder settings from the drawer's folders icon 2026-10-15 02:35:31 +02:00
Linus Rath 8403843e76 fix: ask before signing out 2026-10-15 02:35:30 +02:00
Linus Rath 756ceaa436 fix: leave contact selection and search with back before leaving the tab 2026-10-15 02:35:21 +02:00
Linus Rath 99de74de93 fix: show the logo in the mail header on the light theme 2026-10-15 02:35:21 +02:00
Linus Rath 46d30169b2 fix: clear the mail search with back instead of leaving the app 2026-10-15 02:35:21 +02:00
Linus Rath 512a28c494 fix: show the app in the chosen language right after a cold start 2026-10-15 02:35:11 +02:00
Linus Rath 29c172f2af fix: use the theme's tag colours in the tag sheet 2026-10-15 02:35:11 +02:00
Linus Rath 9e463d3a66 fix: show the whole section labels on the contact page 2026-10-15 02:35:10 +02:00
Linus Rath ab712f11ba fix: say why an event couldn't be saved instead of failing silently 2026-10-15 02:35:10 +02:00
Linus Rath 053fde8d56 fix: keep the text typed in quick reply when opening the full composer 2026-10-15 02:35:09 +02:00
Linus Rath 250c0a9c26 feat: centre the sign-in steps on tablets 2026-10-15 02:35:09 +02:00
Linus Rath d62805f805 docs: tick the rotation and tablet layout findings 2026-10-15 02:11:08 +02:00
Linus Rath b9e6ba417d fix: keep the compose button and list ends above the tablet taskbar 2026-10-15 02:11:08 +02:00
Linus Rath 0087e4c8ff feat: open the composer as a centred panel on tablets 2026-10-15 02:08:18 +02:00
Linus Rath 2470e99dc8 feat: fit the files grid to the width and preview files beside the list on tablets 2026-10-15 02:07:49 +02:00
Linus Rath 8ab82cdeb7 feat: show settings sections and the open page side by side on tablets 2026-10-15 02:07:21 +02:00
Linus Rath 64a62b6306 feat: show contacts as list and details side by side on tablets 2026-10-15 02:06:50 +02:00
Linus Rath f4ff07da7a feat: centre sheets and float toasts bottom right on tablets 2026-10-15 02:06:19 +02:00
Linus Rath 2288d28e46 feat: show five or seven days in the multi-day calendar view on wider screens 2026-10-15 02:05:31 +02:00
Linus Rath 295e292e47 fix: stop the contact and group screens crashing after a delete 2026-10-15 01:49:49 +02:00
Linus Rath 802474d3f2 feat: show mail as folders, list and message side by side on tablets 2026-10-15 01:45:57 +02:00
Linus Rath bfa02f1177 feat: let the app rotate and work in landscape
The app was locked to portrait in app.config.js and the hand-kept
AndroidManifest.xml. Android 16 ignores that lock on screens 600 dp and
wider, so tablets and foldables rotated into layouts that were never
tested. This removes the lock in both places, keeps React state across
rotation and split-screen resizes (`smallestScreenSize` joins
`configChanges`), and fixes what broke in landscape:

- the thread pager sizes its pages from its own width and snaps back to
  the open message after a rotation instead of showing half a neighbour
- compose and the thread view share one keyboard tracker; a rotation
  with the keyboard open closes it, since Android sends no new keyboard
  frame and the old portrait one blanked the whole landscape composer
- in a short window with the keyboard up the composer hides the format
  bar and tightens its header so a few lines of text stay visible
- inputs with suggestions or context around them opt out of Android's
  full-screen landscape keyboard
- month views fit whole rows to the real height, keep the month across a
  remount, and the month dropdown re-measures and caps its height
- the week grid's header follows the remounted list after a rotation
- the event sheet's actions stay on screen in landscape
- every RN Modal allows all orientations, or iOS forces portrait
2026-10-15 01:45:23 +02:00
Linus Rath 4b29cdfd3c feat: add window size classes for tablet and landscape layouts 2026-10-15 01:44:45 +02:00
Linus Rath 2a47913d70 docs: add native ui and ux audit 2026-10-15 01:19:00 +02:00
Linus Rath e19885dc72 chore: update version number to 0.1.75 2026-10-13 18:52:35 +02:00
Linus Rath 4816f0e08b chore: update version number to 0.1.74 2026-10-13 18:07:46 +02:00
Linus Rath ef1d4bc387 fix: stop the app crashing when a notification opens a mail 2026-10-13 18:07:46 +02:00
118 changed files with 3914 additions and 1109 deletions
+33 -35
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useColorScheme } from 'react-native';
import {
DarkTheme, DefaultTheme, NavigationContainer, createNavigationContainerRef, useIsFocused,
@@ -43,10 +44,10 @@ import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
import EmailSourceScreen from './src/screens/EmailSourceScreen';
import LoginScreen from './src/screens/LoginScreen';
import EmailListScreen from './src/screens/EmailListScreen';
import MailTab from './src/screens/MailTab';
import FilesScreen from './src/screens/FilesScreen';
import CalendarScreen from './src/screens/CalendarScreen';
import ContactsScreen from './src/screens/ContactsScreen';
import ContactsTab from './src/screens/ContactsTab';
import ContactDetailScreen from './src/screens/ContactDetailScreen';
import ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen';
@@ -57,12 +58,12 @@ import { useAccountStore } from './src/stores/account-store';
import { useAuthStore } from './src/stores/auth-store';
import { useCalendarStore } from './src/stores/calendar-store';
import { useContactsStore } from './src/stores/contacts-store';
import { useEmailStore, viewerParamsForRow } from './src/stores/email-store';
import { mailboxAccountId } from './src/lib/mailbox-tree';
import { useEmailStore } from './src/stores/email-store';
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
import { useSettingsStore } from './src/stores/settings-store';
import { useLocaleStore } from './src/stores/locale-store';
import { useLayout } from './src/lib/use-layout';
import { toast } from './src/stores/toast-store';
import { useNetworkStore } from './src/stores/network-store';
import { useUpdatesStore } from './src/stores/updates-store';
@@ -243,9 +244,15 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
settings: t('sidebar.settings', 'Settings'),
};
const unavailable = (name: string) => t('sidebar.tab_unavailable', '{name} (unavailable)', { name });
// Side by side layouts move the tabs into a rail on the left, like the
// webmail's navigation rail.
const { isSplit } = useLayout();
// A bottom tab bar keeps the screens clear of the gesture bar or taskbar;
// with the tabs in a side rail the whole tab area has to.
const bottomInset = useSafeAreaInsets().bottom;
return (
<View style={{ flex: 1, backgroundColor: c.background }}>
<View style={{ flex: 1, backgroundColor: c.background, paddingBottom: isSplit ? bottomInset : 0 }}>
<UpdateBanner />
<OfflineCacheBanner />
<PushOnboardingPrompt />
@@ -255,9 +262,21 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
headerShown: false,
// Tabs out of view stop re-rendering until they are shown again.
freezeOnBlur: true,
tabBarPosition: isSplit ? 'left' : 'bottom',
tabBarVariant: isSplit ? 'material' : 'uikit',
// A narrow rail (icon over label), not a wide sidebar with labels
// beside the icons.
...(isSplit ? { tabBarLabelPosition: 'below-icon' as const } : null),
tabBarActiveTintColor: c.text,
tabBarInactiveTintColor: c.textSecondary,
tabBarStyle: {
tabBarStyle: isSplit ? {
backgroundColor: c.secondary,
borderRightColor: c.border,
borderRightWidth: 1,
elevation: 0,
shadowOpacity: 0,
shadowColor: 'transparent',
} : {
backgroundColor: c.background,
borderTopColor: c.border,
borderTopWidth: 1,
@@ -291,30 +310,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
},
}}
>
{() => (
<EmailListScreen
onComposePress={() => navigation.navigate('Compose')}
onEmailPress={(email) => {
// The list holds the open folder's mail: name its account so the
// viewer never has to guess it from whatever folder is open.
const { mailboxes: all, currentMailboxId } = useEmailStore.getState();
const target = {
jmapAccountId: mailboxAccountId(all, currentMailboxId),
// A row of an "All folders" list or a tag view names its own
// account instead, and pages over that account's rows.
...viewerParamsForRow(email),
};
// Start on the body now: mounting the viewer takes a while.
prefetchMessage(email, target.jmapAccountId);
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject,
...target,
});
}}
/>
)}
{() => <MailTab navigation={navigation} />}
</Tab.Screen>
<Tab.Screen
name="Calendar"
@@ -333,7 +329,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
/>
<Tab.Screen
name="Contacts"
component={ContactsScreen}
component={ContactsTab}
options={{
title: tabLabels.contacts,
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
@@ -402,6 +398,7 @@ export default function App() {
// cached mail list instead of the "Restoring session" spinner; the real
// JMAP session comes up in the background.
const hasPersistedAccount = useAccountStore((state) => state.activeAccountId != null);
const { isSplit } = useLayout();
React.useEffect(() => {
if (!hasRestoredSession) {
@@ -832,10 +829,11 @@ export default function App() {
<Stack.Screen
name="Compose"
component={ComposeScreen}
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
}}
// A tablet shows the composer as a panel over the screen it came
// from, so that screen stays drawn behind the dimmed backdrop.
options={isSplit
? { presentation: 'transparentModal', animation: 'fade' }
: { presentation: 'modal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
<Stack.Screen
+1 -1
View File
@@ -1 +1 @@
0.1.73
0.1.76
+1 -1
View File
@@ -25,7 +25,7 @@
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
+1 -1
View File
@@ -31,7 +31,7 @@ module.exports = {
// Android's manifest is maintained by hand and lists the same schemes.
scheme: ['bulwarkmobile', 'bulwarkmail', 'mailto'],
version: VERSION,
orientation: 'portrait',
orientation: 'default',
icon: './assets/icon.png',
userInterfaceStyle: 'automatic',
newArchEnabled: true,
+649
View File
@@ -0,0 +1,649 @@
# Native UI and UX audit, 2026-10
Scope: every screen, sheet, dialog, banner and gesture of the Android app, on a phone and a tablet, in
portrait and landscape. Started from `e19885d` (0.1.75).
## Method
- Devices (emulators):
- phone `Bulwark_Pixel7_API35`, 411x914 dp;
- tablet `Pixel_Tablet_API_34`, 1280x800 dp.
The tablet's natural orientation is landscape, so `user_rotation 1` is tablet portrait.
- Cells used below:
| Cell | Device and orientation |
|---|---|
| PP | phone portrait |
| PL | phone landscape |
| TL | tablet landscape |
| TP | tablet portrait |
"all" means all four.
- Build: release APK from `C:\rn`, with the portrait lock removed for the audit only.
- Data: Stalwart 0.16 on VM 106, seeded with:
- 520+ inbox mails, a 16-message thread, HTML-only newsletters, a wide table, all attachment types
and an inline cid image;
- RTL mail (Arabic, Hebrew), a read-receipt request, very long subjects and names;
- nested folders;
- recurring, all-day, multi-day, overlapping, declined and cancelled events, and tasks;
- 65 contacts with and without photos, plus two groups;
- files and folders, and an active vacation reply.
The phone ran as `usera@`, the tablet as a separate `uxaudit@` account with the same seed.
- Every area was captured in all four cells in light English (tablet) and dark English (phone).
Then a second pass ran with German and font scale 1.3, on PP and TL, with the tablet in dark.
- Four read-only code passes (mail list, thread and compose, calendar, contacts/files/settings/login)
gave the `file:line` references. Device findings were checked against them.
- Screenshots are in the session scratchpad (`shots/q_<screen>.png`, a 4-cell strip: PP, PL, TL, TP;
`shots/d_<screen>.png` for the German/1.3 pass). They are not committed.
## Inventory
### Navigation
- Root native stack (`App.tsx`):
- MainTabs
- EmailThread
- EmailSource
- Compose (modal)
- ContactDetail
- ContactForm (modal)
- GroupDetail
- Scheduled
- UnifiedInbox
- AddAccount (modal)
- Bottom tabs: Mail, Calendar, Contacts, Files, Settings. A tab the server lacks is dimmed and does
nothing.
- Above the tabs:
- UpdateBanner
- OfflineCacheBanner
- PushOnboardingPrompt (floating card)
- AppIconBadge (no UI)
- On the focused stack screen: ToastHost and UndoSnackbar (none on Compose).
- Entry points:
- deep links: `bulwarkmobile://`, webmail permalinks, `mailto:`, and sign-in links
(`bulwarkmail://pair`, `connect`);
- Android share intents into Compose;
- notification taps (mail push, calendar reminders);
- widgets (inbox, agenda), which deep-link.
### Login (`LoginScreen` + `screens/login/*`)
- Steps: choose → email → (discovery) confirm or server → confirm → OAuth hand-off in the browser, or
password (+ TOTP when the server asks) → signing in.
- Side paths:
- QR scan modal (camera permission, blocked → open settings);
- paste-link modal;
- external sign-in link confirm alert;
- refused-link notice;
- Add account mode with a close button.
### Mail
- **Mail list** (`EmailListScreen`):
- Header: menu, folder title, import, logo.
- Search row: select-all, search with recent searches and people suggestions, sort toggle,
filter.
- Filter chips.
- Banners: empty folder, offline, failed outbox, unreachable accounts.
- States: loading, error + retry, no mailboxes, empty / no results.
- Gestures: pull to refresh, endless scroll, long-press to select, swipe actions (instant or
reveal).
- Selection header: star, read, tag, move, spam, archive, delete.
- Compose FAB.
- Sheets: filter modal (with date pickers), MoveSheet, TagSheet, attachment preview.
- **Sidebar drawer** (`SidebarDrawer`):
- Account switcher and account menu: add, set default, sign out, sign out of all.
- Unified Inbox, Scheduled.
- Unified Mailbox section, folders tree with unread counts and long-press folder sheet, shared
accounts, tags.
- Nested ActionSheet and NamePrompt.
- **Unified inbox** (`UnifiedInboxScreen`): its own header, search, selection, swipe, account dots,
pull to refresh, load more.
- **Scheduled** (`ScheduledScreen`): list, per-item actions (send now, reschedule, edit, cancel),
reschedule presets, custom date-time picker.
### Thread view (`EmailThreadScreen` + `components/email/*`)
- Layout:
- Horizontal pager over the list.
- Toolbar: back, delete, archive (≥400 dp), unread (≥340 dp), star, more.
- Subject with star.
- Single message, or conversation cards (collapsed rows, expanded cards with Reply / Reply all /
Forward pills).
- Message header: sender and recipients open the address sheet; "Show details".
- Attachments: preview, open, save and share; "Download all".
- Banners: blocked images, truncated body, read receipt, unsubscribe, calendar invitation (RSVP,
add to calendar).
- Quick reply box.
- Bottom bar: Prev, three configurable actions, Next.
- More sheet: archive, read, move, tag, spam, view mode, sender, forward as attachment, source,
export.
- Email source screen.
### Compose (`ComposeScreen`)
- Header: close, title + draft status, attach, schedule, send.
- Fields:
- From (IdentitySheet, From options);
- To / Cc / Bcc with chips, autocomplete and a chip menu;
- Subject.
- Attachments: sheet with photos, camera, files; chips with progress.
- Body: rich editor WebView or plain text.
- Format bar: templates, read receipt, plain/rich, B/I/U/S, colours, H1/H2, lists, quote, table,
alignment, link, image, clear, undo/redo.
- Modals: link, colour palette, table, schedule sheet, date-time pickers, template placeholders,
TemplateSheet.
- Flows:
- draft autosave;
- close guard (cancel / discard / save draft);
- empty-subject and forgotten-attachment checks;
- undo send through UndoSnackbar;
- scheduled-send confirmation.
### Calendar (`CalendarScreen` + `components/calendar/*`)
- Views: month (free scroll or paged), week (free scroll or paged), 3 days, day, schedule (agenda).
- Header: menu, month title with month dropdown, prev/next (paged only), today, tasks.
- FAB: event, or a menu with Event / Task.
- Sheets and dialogs:
- EventDetailSheet: RSVP, edit, duplicate, .ics, link, delete.
- EventModal (editor): RecurrenceEditor, ParticipantInput, pickers.
- RecurrenceScopeDialog.
- CalendarSidebarDrawer: views, refresh, calendars with a long-press panel, new calendar,
subscriptions, import.
- TasksSheet, ICalImportSheet, ICalSubscriptionSheet, CalendarEditSheet, CalendarShareSheet.
- Gestures:
- tap a slot for a draft, then tap the draft to create;
- long-press a slot to create;
- tap a month cell to open the day;
- free scroll with snapping.
- There is no drag-to-move or drag-to-resize, so there is nothing to break mid-drag.
### Contacts
- `ContactsScreen`:
- Header: drawer, count, search, import, add (long-press: contact or group).
- Address book / group chips.
- Long-press to select.
- Selection header: select all, export, move, tag, delete.
- Pull to refresh.
- `ContactsSidebarDrawer`: books, shared books, groups, tags with rename.
- `ContactDetailScreen`:
- hero and quick actions (email, call, SMS, share);
- sections: email, phone, address, work, personal, online, keys, categories, related, members,
notes, address book;
- recent mail and events;
- More sheet (duplicate, add to group, move, export, delete).
- `ContactFormScreen`: person, organisation or group, with many collapsible sections and a date
picker.
- `GroupDetailScreen`: members, add or remove.
- Sheets: AddressBookPicker, TagAssign, ContactPicker, ContactImport.
### Files (`FilesScreen`)
- Header: back, search, new folder, upload, grid/list.
- Breadcrumb, sort chip, upload progress.
- List or 3-column grid; long-press to select (download zip, delete).
- Per-item ⋮ sheet: preview, open with, download, share, move, duplicate, rename, delete.
- Folder picker, sort sheet, prompt modal, share sheet, preview modal.
### Settings (`SettingsScreen` + `components/settings/*`)
- One screen that swaps between a searchable section list and a page.
- Pages:
- account, language, notifications
- appearance, layout
- reading, composing, identities, vacation, filters (rule modal, sieve editor), templates,
folders, tags, downloads
- security, encryption (disabled), content & senders
- calendar and contacts (with device sync), files, sidebar apps
- about & data, themes, plugins, updates
- Sign out.
### Other
- PushOnboardingPrompt, OfflineBanner, OfflineCacheBanner, UpdateBanner.
- Toasts: swipe to dismiss, action button.
- UndoSnackbar: undo, send now.
- Android widgets (out of scope for fixes): inbox and agenda.
## Findings
Severity: **P1** broken or blocking, **P2** clearly wrong or clumsy, **P3** polish. Each finding
lists the cells where it occurs, the screenshot, the code location and the intended fix. A tick
carries the commit that fixed it.
### Cross-cutting
- [x] **U-001 · P1 · PL TL TP · The app is locked to portrait and has no large-screen layout.** *(fixed in bfa02f1)*
- Where: `app.config.js:34`, `AndroidManifest.xml:28`.
- Android 16 ignores the lock on screens 600 dp and wider, so tablets and foldables rotate anyway.
- Fix: remove the lock in both places. Keep `configChanges` (and add `smallestScreenSize` for
split-screen and foldables). Then do phase 2.
- [x] **U-002 · P2 · iPad, landscape · No RN `<Modal>` sets `supportedOrientations`.** *(fixed in bfa02f1)*
- Where: 56 modals in 40 files.
- iOS presents these portrait-only and forces a rotation.
- Fix: pass all orientations everywhere.
- [x] **U-003 · P2 · TL TP · Every screen is the phone layout stretched to 1280 dp.** *(fixed in 802474d, 64a62b6, 8ab82cd, 2470e99, 0087e4c, 250c0a9)*
- Affected: mail rows, thread body, compose, contacts, files, settings, login.
- Screenshots: `q_mail_list`, `q_thread_receipt`, `q_contacts`, `q_settings`, `t_login_confirm`.
- Fix: the adaptive layout plan below.
- [x] **U-004 · P2 · TL TP · Sheets and floating cards span the full width.** *(fixed in f4ff07d)*
- Affected: bottom sheets, toasts, the undo bar and the push prompt.
- Screenshots: `q_event_detail`, `q_mail_list` (push prompt).
- Fix: on medium and expanded widths, cap them at about 560 dp and centre them.
- [x] **U-005 · P2 · PL · Left and right safe-area insets are ignored.** *(fixed in bfa02f1, f4ff07d, e19229a)* *(partly fixed in bfa02f1 (compose), f4ff07d (sheets))*
- Most screens use `SafeAreaView edges={['top']}` and position sheets `left: 0, right: 0`.
- A side navigation bar or a camera cutout covers the content.
- Fix: add the side edges to screen roots, and inset sheets and the FABs.
- [x] **U-006 · P2 · all · The Files header pads a fixed 60 px instead of the status-bar inset.** *(fixed in cbc41c9)*
- Where: `FilesScreen.tsx:1438`.
- It is too tall in landscape and wrong on any other status bar height.
- [x] **U-007 · P2 · all · Uppercase section labels get clipped on Android.** *(fixed in 9e463d3)*
- Example: "WORK" shows as "WOR", "ADDRESS BOOK" as "ADDRESS" (contact detail, English).
- Cause: Android measures a shrink-wrapped, letter-spaced label a few pixels short, so its
last word wraps onto a second line that the one-line height hides. (Upper-casing the string
instead of `textTransform` made no difference.)
- Where: `ContactDetailScreen.tsx:976`, `ContactActivity.tsx:283`.
- Fix: let the label fill its row (`flex: 1`).
- [x] **U-008 · P2 · all · After a cold start in German, the tab labels and the folder title stay
English.** *(fixed in 512a28c)* The search placeholder is German. Screenshot `t_layo`.
- Fix: re-render the tab labels and the title when the locale store hydrates.
- [x] **U-009 · P2 · TL TP (font 1.3) · Tab labels truncate with plenty of room.** *(fixed in 802474d)*
- Example: "E-…", "Kalen…", "Einstellu…" in 256 dp-wide tabs (`q_de_mail`).
- The label sits beside the icon with a fixed font size and no width.
- Fix: let the label size itself; on wide screens use the side rail (phase 2).
- [x] **U-010 · P2 · all (font 1.3) · The mail search field clips the top of its text.** *(fixed in 7b93265)*
- Where: fixed height in `EmailListScreen.tsx:1885-1912`.
- Fix: use `minHeight` and vertical padding.
- [x] **U-011 · P2 · dark · The dark palette doesn't match the webmail's standard theme.** *(fixed in b3290c3)*
- The app uses a blue primary and zinc greys. The webmail uses a white `#fafafa` primary with
`#171717` text, a `#0a0a0a` ground, neutral greys, `#1c1c1c` popover and a `#262626` muted
surface.
- Where: `src/theme/tokens.ts` `DARK_COLORS`. APP.md lists this as open.
- Fix: port the webmail dark tokens.
- [x] **U-012 · P2 · light · The mail header logo is invisible on the light theme.** *(fixed in 99de74d)*
- It always loads `Bulwark Logo White.png` (`EmailListScreen.tsx:1105`). Screenshot `q_mail_list`
TL.
- Fix: pick the logo by the resolved theme.
- [x] **U-013 · P2 · light · TagSheet reads the static dark palette.** *(fixed in 29c172f)*
- Where: `TagSheet.tsx:7,101,124`. Tag colours are wrong on light.
- Fix: use `c.tags`.
- [x] **U-014 · P2 · all · `Button` has no `accessibilityRole` and no disabled or busy state.** *(fixed in f891bf3)*
- Where: `Button.tsx:35`. This affects every dialog and prompt button.
- [x] **U-015 · P2 · all · Many icon buttons are under 48 dp.** *(fixed in c8434b0,776447d)* *(partly fixed in c8434b0 (thread back, login header, Files header and row menu, account icons, switches, drawer chevron and folder icon, search clear, recipient chip, quick reply))*
- Mail search clear 36, recent-search remove 28, filter chip remove 32, attachment chips about 22,
drawer chevron 32.
- Viewer: card pills about 36, collapse handle 18, quick-reply Send 28, subject star 34.
- Compose: recipient chip X 28, Send 36, Cc/Bcc toggles 28.
- Files: header 44, row ⋮ 26.
- Settings: account icon buttons 28, toggle 24 tall.
- Login: header 32, show-password 20.
- Sheet close buttons are 28 everywhere.
- Lists with `file:line` are in the code passes; the fix commits name the places.
- Fix: a shared 48 dp target (box plus `hitSlop`), applied screen by screen.
- [ ] **U-016 · P3 · all · Sheet and dialog scrims are a literal `rgba(0,0,0,0.5)` in about 20
files.** *(deferred: all scrims are already the same black at 0.5 on both themes; a token is a refactor with no visible change)* Fix: an `overlay` token.
- [ ] **U-017 · P3 · all · Some confirmations use the system alert, others the app's `Dialog`.** *(deferred: switching every confirmation to Dialog changes a lot of flows for a style difference only; see product questions)*
Examples: compose discard, event delete, folder empty. This is a style mismatch.
- [ ] **U-018 · P3 · all · Changing the system font size while the app is open recreates the activity
and resets navigation to the mail list.** *(deferred: handling fontScale in the app (configChanges) was tried and left a mix of old and new text sizes until a restart, worse than Android's default recreate; needs RN to re-measure on a font scale change)* Text measured before the change can stay clipped until
the next restart ("Layo" for "Layout").
### Mail list, drawer, unified inbox, scheduled
- [x] **U-020 · P2 · all · Back with an active search exits the app instead of clearing the
search.** *(fixed in 46d3016)*
- Repro: search "Offsite", press Enter, then Back.
- Where: `EmailListScreen.tsx` has no back handler for search or selection.
- Fix: `useBackWhileFocused` that clears the selection, then the search.
- [x] **U-021 · P2 · PL · Phone landscape shows about 1.5 rows.** *(fixed in b5c4d62)*
- The 56 dp header, the 56 dp search row and the 64 dp tab bar take most of the 411 dp.
- The compose FAB covers the second row. Screenshot `q_mail_list` PL.
- Fix: in compact height, merge the title and search into one row and drop the tab labels.
- [x] **U-022 · P2 · PL (font 1.3) · Rows cut the sender to three letters and clip the time.** *(no change needed: after a restart at 1.3 the rows keep the full time and name; the clipping was stale measurement from changing the font size live, see U-018)*
- Example: "Us…", "17:4" (`q_de_mail` PL).
- Where: the row header in `EmailListScreen.tsx` around 158-250.
- Fix: let the time keep its width (`flexShrink: 0`) and let the name shrink.
- [x] **U-023 · P2 · PP · The undo bar sits on top of the compose FAB for 5 s after every archive or
delete.** *(fixed in 50ab714)*
- Where: `UndoSnackbar.tsx:161`, `EmailListScreen.tsx:2262`.
- Fix: lift the FAB while the bar shows.
- [x] **U-024 · P2 · all · Swipe and bulk actions on the list fail silently.** *(fixed in 0db4bf0)*
- Archive, spam, read, star, move and delete are called as `void ...` with no catch, so a server
rejection becomes an unhandled promise. The selection also stays.
- Where: `EmailListScreen.tsx:604-651, 715-799`; `handleImport` `:492`.
- Fix: report failures through `toast.error` as the viewer does.
- [x] **U-025 · P2 · all · Sign out and "Sign out of all accounts" run with no confirmation.** *(fixed in 8403843)*
- Where: drawer `SidebarDrawer.tsx:930,943`, settings `SettingsScreen.tsx:497`.
- Fix: a destructive `Dialog`.
- [x] **U-026 · P2 · all · The drawer's folder-settings icon only closes the drawer.** *(fixed in d97571c)*
- Where: `SidebarDrawer.tsx:1048`, `navigate('MainTabs')`.
- Fix: open Settings → Folders.
- [x] **U-027 · P2 · all · The filter modal runs a query per keystroke and has no keyboard
handling.** *(fixed in c6cb44d)*
- Where: `EmailListScreen.tsx:1507-1550`.
- Fix: debounce like the main search; add next/search return keys.
- [x] **U-028 · P2 · PP PL · The unified inbox and Scheduled lists end under the gesture bar.** *(fixed in 984c359)*
- Where: `UnifiedInboxScreen.tsx:384`, `ScheduledScreen.tsx:278` (top edge only, fixed padding).
- [x] **U-029 · P2 · all · Unified inbox rows ignore the font-size, density and preview settings.** *(fixed in 52eb18d)*
- Where: `UnifiedInboxScreen.tsx:576`.
- Its load and open failures are silent (`:103-112`, `:179-206`).
- [x] **U-030 · P3 · all · The menu button keeps a grey focus box after the drawer closes.** *(no change needed: did not reproduce after the drawer and rail changes; the button shows no focus box after closing the drawer by tap or Back)*
Screenshot `q_search_results`.
- [x] **U-031 · P3 · PP · The selection header holds up to 7 buttons; its count has no
`numberOfLines`.** *(fixed in 0d96d8d)* Where: `EmailListScreen.tsx:982-1066`.
- [x] **U-032 · P3 · all · Folders under a hidden empty parent show flattened at the top level.** *(no change needed: the tree only lifts a folder whose parent the server does not return; usera has no Clients folder, and on uxaudit Q4 nests under Clients / ACME / 2026)*
- Example: "Q4 contracts" without Clients / ACME / 2026 (`q_drawer` PP).
- This happens with "hide empty folders" on. To check before changing anything.
- [x] **U-033 · P2 · TL TP · With the tabs in the side rail, the compose FAB and list ends sit under
the tablet taskbar.** *(fixed in b9e6ba4)* Found after the mail split view landed (802474d): a bottom tab bar used to
keep the screens clear of the bottom inset, a side rail doesn't.
- Fix: pad the tab area by the bottom inset when the rail is shown.
### Thread view
- [x] **U-040 · P1 · PL TL TP · After a rotation the pager is misaligned.** *(fixed in bfa02f1)*
- Half of the neighbouring page shows and the open message is cut off at the right edge.
- It happens when rotating on the thread, and when rotating on Compose opened from it
(`q_thread_long` PL, `q_compose_close` TL).
- Where: `EmailThreadScreen.tsx:265-280`; the pager keeps its old pixel offset.
- Fix: re-scroll to the active index after a width change, without animation.
- [x] **U-041 · P2 · all · A long thread opens at the oldest message.** *(fixed in 7617064)*
- The newest (opened) message is expanded but off screen on a phone (`q_thread_long` PP).
- Fix: scroll the opened message into view once the cards are laid out.
- [x] **U-042 · P1 · all · Quick reply "More options" throws away the typed text.** *(fixed in 053fde8)*
- Where: `EmailThreadScreen.tsx:1185`.
- Fix: pass the text as `prefillBody` to the reply composer.
- [x] **U-043 · P2 · PL · Phone landscape leaves almost no room for the body.** *(fixed in 975ed08)*
- Toolbar with labels, subject, sender block, attachments, banner and the labelled bottom bar
fill the 411 dp (`q_thread_receipt` PL).
- Fix: icon-only toolbar and bottom bar in compact height.
- [x] **U-044 · P2 · PP (German, 1.3) · The toolbar overflows and pushes More off screen.** *(fixed in 975ed08)*
- "Stern hinzufügen" is long (`d_de_thread`).
- Fix: drop the toolbar labels when they don't fit; keep the a11y labels.
- [ ] **U-045 · P3 · German · Prev/Next read "Zurück"/"Weiter".** *(deferred: the strings come from the vendored webmail catalog (locales/de/common.json); the fix belongs in the webmail's German translation)*
- "Zurück" reads as Back. "Allen antworten" wraps in the bottom bar.
- Fix: "Vorherige"/"Nächste" (locale), and a single line with shrinking text.
- [x] **U-046 · P2 · all · The tag sheet doesn't scroll.** *(fixed in b59d00f)*
- With many tags, or in landscape, rows can't be reached.
- Where: `EmailThreadScreen.tsx:1472-1486`.
- [x] **U-047 · P2 · TL TP · Plain-text and fluid HTML bodies run 150+ characters per line.** *(fixed in e94b782)*
- Fix: a readable max width for the body on wide panes.
- [x] **U-048 · P3 · all · The toolbar star always says "Star", even when the message is starred.** *(fixed in d7cee7c)*
- Where: `EmailThreadScreen.tsx:746`.
- [x] **U-049 · P3 · PL TL · Attachment image preview is sized from the window width, so it is
taller than the screen in landscape.** *(fixed in 2470e99)* Where: `AttachmentPreviewModal.tsx:112`.
### Compose
- [x] **U-060 · P1 · PL · With the keyboard open in landscape, the whole compose body is blank.** *(fixed in bfa02f1)*
- Only the header stays, so you can't see what you type (`tri.png`).
- Where: the keyboard padding is computed from `Dimensions.get('screen').height`
(`ComposeScreen.tsx:400-425`), which doesn't follow the rotation.
- Fix: compute the overlap from the window, against the keyboard frame.
- [x] **U-061 · P2 · all · An untouched reply autosaves a draft and asks "Save or discard?" on
close.** *(fixed in e7857ef)*
- The signature insertion counts as an edit (`d_de_compose`). Opening and closing a reply should
leave nothing behind.
- Where: `ComposeScreen.tsx:1116-1184`, `:1331-1444`.
- [x] **U-062 · P2 · all · Recipient and subject fields have no return-key flow.** *(fixed in 56e1593)*
- Fix: To/Cc/Bcc use `next`; an empty submit moves on; Subject `next` focuses the body.
- Where: `ComposeScreen.tsx:2228-2395`.
- [x] **U-063 · P2 · all (German, 1.3) · Field labels wrap.** *(fixed in 509583e)* "Betreff" shows as "Betref/f": the
label column is a fixed width.
- [x] **U-064 · P3 · PP · The subject field shows the end of a long subject.** *(fixed in 56e1593)*
- Example: "…ning 2027: venue…". The cursor was put at the end on mount.
- [x] **U-065 · P2 · all · Format-bar buttons have no accessibility labels.** *(fixed in af55e0a)*
- Where: `ComposeScreen.tsx:2441-2513`; the toolbar button also has no role or state.
- [x] **U-066 · P2 · PL · Text inputs use Android's full-screen landscape keyboard.** *(fixed in bfa02f1)*
- The autocomplete and the composer are hidden behind it.
- Fix: `disableFullscreenUI` on recipients, subject, plain body and quick reply.
- [x] **U-067 · P2 · TL TP · Compose fills the tablet screen.** *(fixed in 0087e4c)* Fix: a centred panel (phase 2).
- [x] **U-068 · P2 · all · The signature shows two "--" lines.** *(fixed in 90170ea)* The seeded text signature starts
with the usual "-- " delimiter and the composer adds its own separator above it (screenshot
`t_compose_panel`).
- Fix: don't add a separator to a signature that already starts with one.
- [ ] **U-069 · P2 · PL · Recipient suggestions can't show in phone landscape.** *(deferred: with the keyboard up a phone in landscape has about 100 dp left; suggestions need a different design (a full-screen picker), which is a feature)* With the keyboard
up, about 50 dp are left under the To field, so the suggestion list is hidden behind the
keyboard. Typing a full address still works.
### Calendar
- [x] **U-080 · P1 · PL · Month view shows two week rows and cuts off the rest.** *(fixed in bfa02f1)*
- Rows keep a 72 dp minimum and the list is sized to six of them (`q_calendar_default` PL).
- Where: `MonthScrollView.tsx:127-128,250`, `MonthView.tsx:427-438`.
- Fix: size the list to the real height and let rows shrink.
- [x] **U-081 · P1 · TL TP · Rotating on month or week jumps back a month, and week view goes
blank.** *(fixed in bfa02f1)*
- After a rotation, month view showed September instead of October. Week view showed no day
headers and no events until Today was tapped (`q_calendar_default` TP, `q_cal_week` TL).
- Where: `MonthScrollView` samples the visible month at a fixed 40% of a stale height;
`TimeGridScrollView` keeps the old `scrollX` after its list remounts.
- Fix: keep the focused date across the remount and reset the header offset.
- [x] **U-082 · P1 · PL · The event sheet's action row (Edit, Duplicate, .ics, Delete) is below the
screen.** *(fixed in bfa02f1)*
- Screenshot `p_event_land`.
- Where: `EventDetailSheet.tsx:609,643` (body `maxHeight: 400`, no `flexShrink`).
- [x] **U-083 · P1 · all · Saving an event fails silently.** *(fixed in ab712f1)*
- `handleSave` has `try/finally` without catch: the spinner stops, the editor stays, nothing is
said.
- Where: `EventModal.tsx:301-368`.
- [x] **U-084 · P2 · all · Several deletes have no confirmation or undo.** *(fixed in 0460da5)*
- Deleting a single event (`CalendarScreen.tsx:632`, `EventModal.tsx:371`).
- Deleting a task (`TasksSheet.tsx:427`).
- Unsubscribing from a feed, which also deletes its calendar (`ICalSubscriptionSheet.tsx:278`).
- [x] **U-085 · P2 · all · Cancelling the recurrence-scope dialog after editing a recurring event
throws the edits away.** *(fixed in 72b4f4a)* Where: `CalendarScreen.tsx:786-790`.
- [x] **U-086 · P2 · TL TP · Wide screens get wider columns, not more days.** *(fixed in 2288d28)* 3 days stays 3 and
day stays 1. Fix: phase 2.
- [x] **U-087 · P2 · PL · The month dropdown is a fixed 346 dp tall and sized from the window.** *(fixed in bfa02f1)*
- It overflows in landscape and lands on a half page after rotation.
- Where: `MonthDropdown.tsx:23,52,72`.
- [x] **U-088 · P3 · all · Times and dates use English formats in other languages.** *(fixed in cf7275c)*
- Hour labels hard-code AM/PM (`TimeGridScrollView.tsx:437,640`, `WeekView.tsx:234,318`).
- Dates use US-order patterns (`EventDetailSheet.tsx:96-108`, `EventModal.tsx:525,545`,
`TasksSheet.tsx:212`).
- The AM/PM labels only show with the 12-hour setting, where they are the right notation; left
as is.
- [x] **U-089 · P3 · all · ICalSubscriptionSheet and RecurrenceEditor are English only.** *(no change needed: both components already go through t() and every key has a German entry; the parity doc is out of date)* From
`docs/parity/05-calendar.md`.
- [x] **U-090 · P3 · PP (font 1.3) · Month chips show only the start time, no title.** *(fixed in 95c3163)*
- [x] **U-091 · P3 · all · An event that starts above the visible hours shows as an untitled
block.** *(no change needed: this is a block scrolled partly out of the top of the grid, so its title is above the viewport; Google Calendar and Apple Calendar behave the same)*
### Contacts
- [x] **U-100 · P1 · all · Deleting a contact or group crashes the detail screen.** *(fixed in 295e292)*
- Hooks run after an early return: `ContactDetailScreen.tsx:149` before `:216`, and
`GroupDetailScreen.tsx:58` before `:130`.
- The delete empties the store, the screen re-renders with fewer hooks, and React throws. A
contact removed by a sync does the same.
- [x] **U-101 · P2 · PL · The address-book chip row is clipped at the bottom.** *(fixed in 0b37102)* Screenshot
`q_contacts` PL.
- [x] **U-102 · P2 · all · Back doesn't leave selection or search; it leaves the tab.** *(fixed in 756ceaa)*
- Where: `ContactsScreen.tsx`. Files and Settings already handle this.
- [x] **U-103 · P2 · all · The contact form's discard check only covers the on-screen back.** *(fixed in 1ebc5c6)*
- Hardware back drops edits silently.
- Where: `ContactFormScreen.tsx:764`. Fix: `usePreventRemove`.
- [x] **U-104 · P2 · all · Contact form fields have no next or submit chaining.** *(fixed in 33dc3cd)* Where:
`ContactFormScreen.tsx:1197-1283`.
- [x] **U-105 · P2 · TL TP · List and detail are separate full-screen pages.** *(fixed in 64a62b6)* Fix: phase 2.
### Files
- [x] **U-110 · P2 · all · The grid has a fixed 3 columns.** *(fixed in 2470e99)*
- The last row stretches its cells, so the columns don't line up. Tablet cells are huge
(`q_files_grid`).
- Where: `FilesScreen.tsx:989,1606`.
- [x] **U-111 · P2 · all · Dates and times ignore the app's language and the 12/24 h setting.** *(fixed in 63eefd7)*
- Example: "10:47 PM" next to 24 h times everywhere else.
- Where: `FilesScreen.tsx:127-132`.
- [x] **U-112 · P2 · all · Removing someone's share access has no confirmation.** *(fixed in d4982f6)* Where:
`ShareSheet.tsx:213`.
- [x] **U-113 · P2 · TL TP · There is no side-by-side preview.** *(fixed in 2470e99)* Fix: phase 2.
### Settings
- [x] **U-120 · P2 · all · Deleting a tag has no confirmation, while the reset next to it does.** *(fixed in 275d674)*
Where: `KeywordSettings.tsx:48,122`.
- [x] **U-121 · P2 · PP · The vacation subject input is a fixed 240 dp.** *(fixed in f22e851)*
- It squeezes its label into five lines.
- Where: `VacationSettings.tsx:434`; the same pattern in `AccountSecuritySettings.tsx:234`.
- [x] **U-122 · P2 · light · The amber banner in filters and the sieve editor is unreadable on
light.** *(fixed in 8f68d24)*
- Light yellow text on a faint brown fill.
- Where: `FilterSettings.tsx:515-519`, `SieveEditorSheet.tsx:208-212`.
- [x] **U-123 · P2 · TL TP · The section list and the page replace each other.** *(fixed in 8ab82cd)* Fix: phase 2.
- [ ] **U-124 · P3 · all · The language list is in neither name nor code order.** *(deferred: the order copies the webmail language switcher on purpose (comment in src/i18n/index.ts); change both together)* Example: Česky,
Slovenčina, Dansk.
### Login
- [x] **U-130 · P2 · TL TP · Login steps stretch to the full width.** *(fixed in 250c0a9)*
- Fields and buttons are 1248 dp wide (`t_login_confirm`).
- Fix: a centred column of about 480 dp.
- [x] **U-131 · P2 · all · Small targets: show-password 20 dp, header back 32 dp, email suggestion
chips about 26 dp.** *(fixed in ae1ef5b,c8434b0,776447d)* *(partly fixed in ae1ef5b (show password), c8434b0 (header); email suggestion chips still small)*
- [x] **U-132 · P2 · all · The password step's return keys don't move on.** *(fixed in ae1ef5b)*
- Username has no `next`. With TOTP on, the password field shows Next and does nothing.
- Where: `PasswordStep.tsx:56,78`.
- [ ] **U-133 · P2 · all · There is no way out while "Signing in" hangs.** *(deferred: a cancel needs an abortable sign-in in the auth flow, which is more than a UI fix; see product questions)* Hardware back is
swallowed. Where: `SigningInStep.tsx`, `LoginScreen.tsx:165`.
### Found in the re-walk
- [x] **U-134 · P3 · all · The all-day label in the day and week grid reads "All".** A one-row
strip cut the second line of "All day" ("Ganztägig" in German). *(fixed in e452f72)*
- [x] **U-135 · P2 · PP, split · The selection header pushed Delete off the screen.** Eight 44 dp
buttons with 12 dp gaps don't fit 412 dp; the count also kept its screen-reader label on the
normal header afterwards. *(fixed in 0d96d8d)*
- [ ] **U-136 · P3 · all · The day view keeps an empty all-day strip on days without all-day
events.** The strip takes the height of the busiest day in the loaded range so the grid doesn't
jump while paging (comment in `TimeGridScrollView.tsx`). *(deferred: a product call between a
steady grid and less blank space; see product questions)*
## Adaptive layout plan
**Breakpoints** come from the window width (`useWindowDimensions`), not the device type, so
split-screen, freeform windows and foldables work too:
| Class | Width | Typical |
|---|---|---|
| compact | < 600 dp | phones in portrait, narrow split-screen |
| medium | 600–839 dp | tablet portrait, unfolded foldables |
| expanded | ≥ 840 dp | tablet landscape |
A second flag, `shortHeight` (window height < 480 dp), covers phone landscape (914x411 dp). By
width alone that would be "expanded", but two panes and a rail in 411 dp of height are worse than
one pane. So a short window keeps the compact layouts and only compresses chrome: icon-only bars,
a merged header row, no tab labels.
A small hook, `src/lib/use-layout.ts`, returns `{ widthClass, shortHeight, isSplit }` and a pure
`layoutFor(width, height)` that can be tested.
**Navigation.** The phone stack stays as it is. A split view is a layout inside a tab, not a second
navigator:
- Medium and expanded put the tab bar on the left as a rail (`tabBarPosition: 'left'`, material
variant), like the webmail's navigation rail.
- Stack routes still exist and are still used on compact widths. On medium and expanded, the list
screens render the detail in a right-hand pane instead of pushing, and the detail screens' bodies
are shared components that take props instead of route params.
- If the window narrows while a pane shows something, the pane's selection is pushed as the normal
stack route, so nothing is lost.
**Per area:**
| Area | compact | medium | expanded |
|---|---|---|---|
| Mail | list → push thread | list (360 dp) + thread pane | persistent folder sidebar (280 dp) + list + thread |
| Calendar | as today | wider grid, 3-day shows 5 days | week shows 7 wide columns, 3-day shows 7, day shows 2 |
| Contacts | list → push detail | list + detail pane | list + detail pane |
| Files | list / grid | grid columns from width (about 140 dp each) | grid columns from width + preview pane when a file is selected |
| Settings | list ↔ page | section list (320 dp) + page | section list + page |
| Compose | full screen | centred panel, max 720 dp, dimmed backdrop | same |
| Sheets, dialogs, toasts | full width at the bottom | max 560 dp, centred | max 560 dp, centred |
| Login | as today | centred column, max 480 dp | same |
The thread, contact detail and settings pages cap their reading width (about 760 dp for the
mail body, 640 dp for forms) inside wide panes.
**State across rotation.** The activity keeps its state through `configChanges` (orientation,
screen size, smallest screen size, layout). Panes keep their selection in component state or the
store. Lists that depend on width remount with their focused index or date carried over.
## Final report (2026-10-15)
### Result
86 findings (U-001 to U-136; numbers were skipped where a candidate turned out to be a duplicate).
| Severity | Fixed | No change needed | Deferred |
| --- | --- | --- | --- |
| P1 | 9 | 0 | 0 |
| P2 | 57 | 1 | 2 |
| P3 | 7 | 4 | 6 |
Every logic change has a vitest test that fails without it. The full suite (3273 tests), `tsc`
and `npm run i18n:check` pass at the end.
### Areas and cells after the last walk
PP = phone portrait, PL = phone landscape, TP = tablet portrait, TL = tablet landscape. The last
walk ran the phone in German and dark, the tablet in English and light. Font scale 1.3 was checked
on PL and TL after a restart.
| Area | PP | PL | TP | TL |
| --- | --- | --- | --- | --- |
| Mail list, drawer, selection | ok | ok (search in header) | ok (list + message) | ok (folders + list + message) |
| Thread | ok | ok (icon-only toolbar) | ok (docked) | ok (docked) |
| Compose | ok | ok (format bar hidden while typing) | ok (panel) | ok (panel) |
| Calendar month / week / day | ok | ok | ok | ok |
| Contacts list, detail, form | ok | ok | ok (split) | ok (split) |
| Files and preview | ok | ok | ok (docked preview) | ok (docked preview) |
| Settings | ok | ok | ok (split) | ok (split) |
| Login | ok | ok | ok (centred column) | ok (centred column) |
### Deferred
- U-016 scrim token: all scrims are already the same; a refactor with no visible change.
- U-017 system alert vs Dialog: a style mismatch only; see the questions below.
- U-018 font size change while the app is open: handling `fontScale` in the app was tried and left
mixed old and new text sizes, worse than Android's recreate.
- U-045 "Zurück"/"Weiter": the strings come from the webmail's German catalog.
- U-069 recipient suggestions in phone landscape: no room above the keyboard; needs a new picker.
- U-124 language order: copies the webmail switcher on purpose.
- U-133 no way out of a hung sign-in: needs an abortable sign-in in the auth flow.
- U-136 empty all-day strip in the day view: a product call; see below.
### Product questions
1. Dark-mode links stay blue (`#60a5fa`) while the rest follows the webmail's neutral palette.
Recommendation: keep blue; links in mail bodies need to stand out.
2. Should every confirmation use the app's Dialog instead of the system alert (U-017)?
Recommendation: yes, for destructive actions first, as one pass of its own.
3. Sign-in that hangs (U-133): add a Cancel that aborts discovery and the token request.
Recommendation: yes; it is the only screen with no way back.
4. Day view all-day strip (U-136): keep one height across days, or size it to the day on screen?
Recommendation: size it to the visible days once paging settles.
5. Month view on a phone now shows titles instead of start times even with "show time" on, since
the time filled the whole chip. Recommendation: keep; times come back where cells are wide.
6. Files keeps a full-screen preview on phones and docks it on tablets. Recommendation: keep.
7. The dark background of plain-text mail is fixed at `#0a0a0a` and ignores the built-in themes.
Recommendation: take it from the theme palette.
8. APP.md still lists the dark palette as open; the app now uses the webmail standard theme. The
note should be updated in the branding repo.
### Not tested
- iOS and iPad: nothing was run. The risk is in the `supportedOrientations` change on every Modal,
the keyboard tracker (built around Android's missing frame after rotation) and the safe-area
insets in landscape.
- Real phones and tablets, foldables (fold and unfold mid-task), split-screen on real hardware.
- Push notifications and their deep links.
- Home-screen widgets: they truncate German labels; not changed here.
- The test account came from a login created for this audit ("Stalwart test – uxaudit@example.org"
in the Agent collection), because the "Stalwart test servers" item was not visible to the agent.
+13
View File
@@ -128,12 +128,14 @@
"add_placeholder": "Aufgabe hinzufügen…",
"add_time": "Uhrzeit hinzufügen",
"clear_due_date": "Fälligkeitsdatum entfernen",
"delete_confirm": "Diese Aufgabe löschen?",
"delete_error": "Aufgabe konnte nicht gelöscht werden",
"not_found": "Diese Aufgabe ist nicht mehr verfügbar.",
"save_error": "Aufgabe konnte nicht gespeichert werden",
"update_error": "Aufgabe konnte nicht aktualisiert werden"
},
"views": {
"n_days": "{count} Tage",
"schedule": "Terminübersicht",
"three_days": "3 Tage"
}
@@ -310,6 +312,10 @@
"send_now": "Jetzt senden",
"send_timeout_body": "Die Nachricht wurde möglicherweise bereits versendet. Prüfen Sie Ihren Gesendet-Ordner, bevor Sie sie erneut senden.",
"send_timeout_title": "Keine Antwort vom Server",
"toolbar": {
"insert_image": "Bild einfügen",
"remove_link": "Link entfernen"
},
"undo_send_label": "Senden in {seconds} s…",
"upload_failed_short": "Upload fehlgeschlagen",
"uploading": "Wird hochgeladen...",
@@ -447,6 +453,7 @@
"share_no_matches": "Keine Treffer",
"share_no_users": "Keine anderen Benutzer gefunden",
"share_remove": "Zugriff entfernen",
"share_remove_confirm": "Zugriff entfernen?",
"share_role_custom": "Benutzerdefiniert",
"share_search_placeholder": "Nach Name oder E-Mail suchen",
"share_unavailable": "Teilen ist auf diesem Gerät nicht verfügbar",
@@ -866,6 +873,7 @@
"text_signature": "Klartext-Signatur"
},
"keywords": {
"delete_confirm_title": "Tag „{label}“ löschen?",
"description_mobile": "Farbige Labels zum Organisieren Ihrer E-Mails.",
"discover": {
"add_all": "Alle hinzufügen",
@@ -1128,6 +1136,11 @@
"remove_account_message": "Von {account} auf diesem Gerät abmelden. Ihre E-Mails bleiben auf dem Server.",
"remove_account_title": "Konto entfernen?",
"show_unread": "Ungelesene anzeigen",
"sign_out_all_confirm_message": "Alle Konten werden von diesem Gerät entfernt. Sie können sich jederzeit wieder anmelden.",
"sign_out_all_confirm_title": "Von allen Konten abmelden?",
"sign_out_confirm_message": "Das Konto wird von diesem Gerät entfernt. Sie können sich jederzeit wieder anmelden.",
"sign_out_confirm_title": "Abmelden?",
"sign_out_confirm_title_account": "Von {account} abmelden?",
"tab_unavailable": "{name} (nicht verfügbar)"
},
"unified_mailbox": {
+12 -1
View File
@@ -131,12 +131,14 @@
"add_placeholder": "Add a task…",
"add_time": "Add time",
"clear_due_date": "Clear due date",
"delete_confirm": "Delete this task?",
"delete_error": "Failed to delete task",
"not_found": "This task is no longer available.",
"save_error": "Failed to save task",
"update_error": "Failed to update task"
},
"views": {
"n_days": "{count} days",
"schedule": "Schedule",
"three_days": "3 days"
}
@@ -321,7 +323,9 @@
"send_timeout_title": "No answer from the server",
"toolbar": {
"background_color": "Background color",
"remove_background_color": "Remove background color"
"insert_image": "Insert image",
"remove_background_color": "Remove background color",
"remove_link": "Remove link"
},
"undo_send_label": "Sending in {seconds}s…",
"upload_failed_short": "Upload failed",
@@ -460,6 +464,7 @@
"share_no_matches": "No matches",
"share_no_users": "No other users found",
"share_remove": "Remove access",
"share_remove_confirm": "Remove access?",
"share_role_custom": "Custom",
"share_search_placeholder": "Search by name or email",
"share_unavailable": "Sharing is not available on this device",
@@ -885,6 +890,7 @@
"text_signature": "Plain-text signature"
},
"keywords": {
"delete_confirm_title": "Delete tag \"{label}\"?",
"description_mobile": "Colored tags to organize your mail.",
"discover": {
"add_all": "Add all",
@@ -1147,6 +1153,11 @@
"remove_account_message": "Sign out of {account} on this device. Your mail stays on the server.",
"remove_account_title": "Remove account?",
"show_unread": "Show unread",
"sign_out_all_confirm_message": "Every account is removed from this device. You can sign in again at any time.",
"sign_out_all_confirm_title": "Sign out of all accounts?",
"sign_out_confirm_message": "The account is removed from this device. You can sign in again at any time.",
"sign_out_confirm_title": "Sign out?",
"sign_out_confirm_title_account": "Sign out of {account}?",
"tab_unavailable": "{name} (unavailable)"
},
"unified_mailbox": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.73",
"version": "0.1.76",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.73",
"version": "0.1.76",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.73",
"version": "0.1.76",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+10
View File
@@ -15,8 +15,13 @@ interface ButtonProps {
loading?: boolean;
icon?: React.ReactNode;
style?: ViewStyle;
/** Needed for an icon-only button; a text button reads its text. */
accessibilityLabel?: string;
}
// Extra touch area so even the small sizes reach a 48 dp target.
const HIT_SLOP: Record<ButtonSize, number> = { sm: 6, md: 4, lg: 2, icon: 4 };
export default function Button({
variant = 'default',
size = 'md',
@@ -26,6 +31,7 @@ export default function Button({
loading = false,
icon,
style,
accessibilityLabel,
}: ButtonProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -43,6 +49,10 @@ export default function Button({
]}
onPress={onPress}
disabled={isDisabled}
hitSlop={HIT_SLOP[size]}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ disabled: isDisabled, busy: loading }}
>
{loading ? (
<ActivityIndicator
+2 -1
View File
@@ -5,6 +5,7 @@ import Button from './Button';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useLocaleStore } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface DialogInput {
value: string;
@@ -55,7 +56,7 @@ export default function Dialog({
const confirmLabel = confirmText ?? t('common.confirm', 'Confirm');
const cancelLabel = cancelText ?? t('common.cancel', 'Cancel');
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<View style={styles.backdrop}>
<View style={styles.dialog}>
<View style={styles.content}>
+7 -3
View File
@@ -9,6 +9,8 @@ import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
import type { Identity } from '../api/types';
import { useLocaleStore } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
interface IdentitySheetProps {
visible: boolean;
@@ -25,6 +27,8 @@ export function IdentitySheet({
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
const { t } = useLocaleStore();
@@ -44,14 +48,14 @@ export function IdentitySheet({
}, [visible, slideY, overlayOpacity]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -60,7 +64,7 @@ export function IdentitySheet({
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('email_composer.from', 'From')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}
<Pressable onPress={onClose} hitSlop={10} style={styles.sheetClose}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
+5 -1
View File
@@ -9,6 +9,8 @@ interface InputProps extends TextInputProps {
containerStyle?: ViewStyle;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
/** React 19 passes `ref` as a prop; it reaches the TextInput (focus chaining). */
ref?: React.Ref<RNTextInput>;
}
/**
@@ -27,6 +29,7 @@ export default function Input({
leftIcon,
rightIcon,
style,
ref,
...props
}: InputProps) {
const c = useColors();
@@ -44,6 +47,7 @@ export default function Input({
]}>
{leftIcon && <View style={styles.iconLeft}>{leftIcon}</View>}
<RNTextInput
ref={ref}
{...props}
style={[styles.input, style]}
placeholderTextColor={c.textMuted}
@@ -67,7 +71,7 @@ function makeStyles(c: ThemePalette) {
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.inputHeight, // h-10 = 40px
minHeight: componentSizes.inputHeight, // h-10 = 40px; grows with a larger system font
borderWidth: 1,
borderColor: c.border, // border-input
borderRadius: radius.sm, // rounded-md = 6px
+7 -3
View File
@@ -14,6 +14,8 @@ import { useSheetDrag } from '../lib/use-sheet-drag';
import { useLocaleStore } from '../stores/locale-store';
import { localizeMailboxName } from '../lib/mailbox-label';
import type { Mailbox } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
const lower = name.toLowerCase();
@@ -43,6 +45,8 @@ export function MoveSheet({
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -76,14 +80,14 @@ export function MoveSheet({
}, [mailboxes]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -92,7 +96,7 @@ export function MoveSheet({
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('context_menu.move_to', 'Move to folder')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}
<Pressable onPress={onClose} hitSlop={10} style={styles.sheetClose}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
+2 -1
View File
@@ -19,6 +19,7 @@ import Button from './Button';
import { useLocaleStore } from '../stores/locale-store';
import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
import { describeLoginError } from '../lib/login-errors';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface PasteSignInLinkModalProps {
visible: boolean;
@@ -80,7 +81,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
const title = t('login.mobile.paste_title', 'Paste sign-in link');
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
<View style={styles.header}>
+5 -6
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFloatingCardFrame } from '../lib/use-layout';
import { BellRing, X } from 'lucide-react-native';
import Button from './Button';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -24,8 +25,6 @@ import {
// Mirrors the webmail's push-notification-prompt: a short delay after login
// so the inbox renders first, and never again for an account once dismissed.
const PROMPT_DELAY_MS = 1500;
// Clears the tab bar, like the toasts (ToastHost).
const TAB_BAR_CLEARANCE = 64;
/**
* Once-per-account invitation to turn on background notifications. Shown
@@ -51,6 +50,8 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const insets = useSafeAreaInsets();
// Full width above the tab bar on a phone; bottom right on a tablet.
const frame = useFloatingCardFrame();
const duration = useAnimDuration(200);
const appear = React.useRef(new Animated.Value(0)).current;
@@ -117,7 +118,7 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
style={[
styles.wrap,
{
bottom: insets.bottom + TAB_BAR_CLEARANCE,
left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom,
opacity: appear,
transform: [{ translateY: appear.interpolate({ inputRange: [0, 1], outputRange: [24, 0] }) }],
},
@@ -149,7 +150,7 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
</View>
<Pressable
onPress={handleDismiss}
hitSlop={8}
hitSlop={14}
accessibilityRole="button"
accessibilityLabel={t('settings.notifications.push.dismiss_aria', 'Dismiss notification prompt')}
style={styles.close}
@@ -165,8 +166,6 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
zIndex: 50,
},
card: {
+2 -1
View File
@@ -7,6 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
import { useColors, useResolvedTheme } from '../theme/colors';
import Button from './Button';
import { useLocaleStore } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface QrScanModalProps {
visible: boolean;
@@ -42,7 +43,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<View style={styles.container}>
{permission?.granted ? (
<CameraView
+5
View File
@@ -65,6 +65,8 @@ export interface RichTextEditorHandle {
*/
getHtml(timeoutMs?: number): Promise<string>;
focus(): void;
/** Drop the caret, which closes the keyboard. */
blur(): void;
}
interface Props {
@@ -176,6 +178,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
focus: () => {
call(`window.__rne && window.__rne.focus()`);
},
blur: () => {
call(`document.activeElement && document.activeElement.blur()`);
},
}), [call]);
const onMessage = (event: WebViewMessageEvent) => {
+439 -384
View File
@@ -37,6 +37,9 @@ import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
import type { Mailbox } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { confirmSignOut } from '../lib/sign-out-confirm';
import { setPendingSettingsTab } from '../navigation/pending-settings-tab';
const CHEVRON_SLOT = 20;
const INDENT_STEP = 12;
@@ -183,7 +186,9 @@ function SidebarRow({
{hasChildren ? (
<Pressable
onPress={onToggleExpand}
hitSlop={6}
// The 20 dp slot keeps the tree indent; the touch area reaches
// 44 dp so a near miss doesn't open the folder instead.
hitSlop={12}
style={styles.chevron}
accessibilityRole="button"
accessibilityLabel={isExpanded ? t('sidebar.collapse', 'Collapse') : t('sidebar.expand', 'Expand')}
@@ -228,7 +233,7 @@ function ActionSheet({ title, actions, onClose }: { title: string; actions: Shee
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Modal visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
<Pressable style={styles.sheetOverlay} onPress={onClose} />
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>{title}</Text>
@@ -270,7 +275,7 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }
onSubmit(name);
};
return (
<Modal visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
<Pressable style={styles.sheetOverlay} onPress={onClose} />
<View style={styles.promptCard}>
<Text style={styles.sheetTitle}>{title}</Text>
@@ -301,9 +306,11 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }
interface SidebarDrawerProps {
visible: boolean;
onClose: () => void;
/** Render as a permanent sidebar (wide screens) instead of a modal drawer. */
docked?: boolean;
}
export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) {
export default function SidebarDrawer({ visible, onClose, docked = false }: SidebarDrawerProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -745,8 +752,424 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
const tagViewActive = !!filters.keyword;
const panel = (
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
{/* Header: close + account switcher */}
<View style={styles.header}>
{!docked && (
<Pressable
onPress={onClose}
style={styles.headerClose}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={20} color={c.text} />
</Pressable>
)}
<Pressable
onPress={() => setAccountMenuOpen((v) => !v)}
style={({ pressed }) => [styles.account, pressed && styles.accountPressed]}
>
<View style={[styles.accountAvatar, { backgroundColor: avatarBg }]}>
<Text style={styles.accountAvatarText}>{initials}</Text>
</View>
<View style={styles.accountInfo}>
<Text style={styles.accountName} numberOfLines={1}>{username || t('settings.account.title', 'Account')}</Text>
{hostname ? (
<Text style={styles.accountEmail} numberOfLines={1}>{hostname}</Text>
) : null}
</View>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<ChevronDown
size={16}
color={c.textMuted}
style={accountMenuOpen ? styles.accountChevronOpen : undefined}
/>
)}
</Pressable>
</View>
{accountMenuOpen && (
<View style={styles.accountMenu}>
<ScrollView style={styles.accountMenuList}>
{accounts.map((acc) => {
const isActive = acc.id === activeAccountId;
let accHost = '';
try { accHost = new URL(acc.serverUrl).hostname; } catch { accHost = acc.serverUrl; }
const accEmail = acc.email || (acc.username.includes('@')
? acc.username
: accHost ? `${acc.username}@${accHost}` : acc.username);
return (
<Pressable
key={acc.id}
onPress={() => {
if (isActive) return;
setAccountMenuOpen(false);
onClose();
void switchAccount(acc.id);
}}
onLongPress={isActive ? undefined : () => confirmRemoveAccount(acc)}
delayLongPress={350}
accessibilityHint={isActive ? undefined : t('sidebar.remove_account_hint', 'Long-press to remove this account')}
style={({ pressed }) => [
styles.accountMenuRow,
isActive && styles.accountMenuRowActive,
pressed && !isActive && styles.accountMenuActionPressed,
]}
>
<View style={styles.accountMenuAvatarWrap}>
<View style={[styles.accountMenuAvatar, { backgroundColor: acc.avatarColor }]}>
<Text style={styles.accountMenuAvatarText}>
{getAccountInitials(acc.displayName, acc.email || acc.username)}
</Text>
</View>
{isActive && (
<View style={styles.accountMenuCheckBadge}>
<Check size={10} color={c.primaryForeground} strokeWidth={3} />
</View>
)}
</View>
<View style={styles.accountMenuInfo}>
<View style={styles.accountMenuNameRow}>
<Text style={styles.accountMenuName} numberOfLines={1}>
{acc.displayName || acc.username}
</Text>
{acc.isDefault && (
<Star size={12} color="#f59e0b" fill="#f59e0b" />
)}
</View>
<Text style={styles.accountMenuHost} numberOfLines={1}>{accEmail}</Text>
<View style={styles.accountMenuStatusRow}>
{acc.hasError ? (
<>
<AlertTriangle size={10} color={c.error} />
<Text style={[styles.accountMenuStatusText, { color: c.error }]} numberOfLines={1}>
{acc.errorMessage || t('sidebar.account_error', 'Needs attention')}
</Text>
</>
) : (
<>
{/* The connection dot only means something for the
account the live session serves. */}
{isActive && (
<View style={[
styles.accountMenuStatusDot,
!acc.isConnected && styles.accountMenuStatusDotOffline,
]} />
)}
<Text style={styles.accountMenuStatusText} numberOfLines={1}>{accHost}</Text>
</>
)}
</View>
</View>
{!isActive && (
<Pressable
onPress={() => confirmRemoveAccount(acc)}
hitSlop={8}
style={styles.accountMenuRemove}
accessibilityRole="button"
accessibilityLabel={t('sidebar.remove_account', 'Remove account')}
>
<UserMinus size={14} color={c.textMuted} />
</Pressable>
)}
</Pressable>
);
})}
</ScrollView>
{accounts.length < MAX_ACCOUNTS && (
<>
<View style={styles.accountMenuDivider} />
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => {
setAccountMenuOpen(false);
onClose();
navigation.navigate('AddAccount');
}}
>
<Plus size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>{t('sidebar.add_account', 'Add account')}</Text>
</Pressable>
</>
)}
<View style={styles.accountMenuDivider} />
{accounts.length > 1 && activeAccountId && (() => {
const active = accounts.find((a) => a.id === activeAccountId);
if (!active || active.isDefault) return null;
return (
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => setDefaultAccount(active.id)}
>
<Star size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>{t('sidebar.set_as_default', 'Set as default')}</Text>
</Pressable>
);
})()}
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => {
setAccountMenuOpen(false);
confirmSignOut(t, { account: accountEmailFull || undefined }, () => { onClose(); void logout(); });
}}
>
<LogOut size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText} numberOfLines={1}>
{t('sidebar.sign_out_of', `Sign out of ${accountEmailFull || 'account'}`, { account: accountEmailFull || 'account' })}
</Text>
</Pressable>
{accounts.length > 1 && (
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => {
setAccountMenuOpen(false);
confirmSignOut(t, { all: true }, () => { onClose(); void logoutAll(); });
}}
>
<LogOut size={16} color={c.error} />
<Text style={[styles.accountMenuActionText, { color: c.error }]}>
{t('sidebar.sign_out_all', 'Sign out of all accounts')}
</Text>
</Pressable>
)}
</View>
)}
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{/* Quick views */}
{showUnified && (
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: 'inbox' }); }}
>
<Layers size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>
{unifiedCrossAccount && accounts.length > 1
? t('sidebar.all_accounts', 'All accounts')
: t('sidebar.unified_inbox', 'All inboxes')}
</Text>
<View style={{ flex: 1 }} />
<RowCounts unread={unifiedCounts.inbox.unread} total={unifiedCounts.inbox.total} showTotal={showFolderTotalCount} />
</Pressable>
)}
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('Scheduled'); }}
>
<Clock size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>{t('sidebar.scheduled', 'Scheduled')}</Text>
</Pressable>
{/* Unified per-role and cross views */}
{showUnified && (
<>
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('unifiedExpanded', setUnifiedExpanded)}>
{unifiedExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.unified_mailbox', 'Unified mailbox')}</Text>
</Pressable>
{unifiedExpanded && (
<>
{([
{ view: 'unread' as const, Icon: MailOpen, label: t('sidebar.unified_all_unread', 'All unread'), unread: unifiedCounts.crossUnread },
{ view: 'starred' as const, Icon: Star, label: t('sidebar.unified_all_starred', 'All starred'), unread: 0 },
{ view: 'all' as const, Icon: Mails, label: t('sidebar.unified_all_mail', 'All mail'), unread: unifiedCounts.crossUnread },
]).map((row) => (
<SidebarRow
key={row.view}
icon={<row.Icon size={16} color={c.textSecondary} />}
label={row.label}
depth={0}
isSelected={false}
unread={row.unread}
total={0}
showTotal={false}
hasChildren={false}
isExpanded={false}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { view: row.view }); }}
onToggleExpand={() => {}}
/>
))}
{unifiedCounts.roles.map((r) => {
const Icon = unifiedIcon(r.role);
return (
<SidebarRow
key={r.role}
icon={<Icon size={16} color={iconColor(c, r.role, false)} />}
label={t(`sidebar.unified_${r.role}`, `All ${r.role}`)}
depth={0}
isSelected={false}
unread={r.unread}
total={r.total}
showTotal={showFolderTotalCount}
hasChildren={false}
isExpanded={false}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: r.role }); }}
onToggleExpand={() => {}}
/>
);
})}
</>
)}
</>
)}
{/* Folders section */}
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}>
{foldersExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.folders', 'Folders')}</Text>
<View style={{ flex: 1 }} />
<Pressable
style={styles.sectionSettings}
hitSlop={8}
// Opens the folder settings; it used to navigate to the screen
// already shown, which only closed the drawer.
onPress={() => {
onClose();
setPendingSettingsTab('folders');
navigation.navigate('MainTabs', { screen: 'Settings' } as never);
}}
accessibilityRole="button"
accessibilityLabel={t('settings.tabs.folders', 'Folders')}
>
<Settings size={14} color={c.textMuted} />
</Pressable>
</Pressable>
{foldersExpanded && (
mailboxes.length === 0 ? (
<Text style={styles.empty}>{t('sidebar.loading_mailboxes', 'Loading mailboxes...')}</Text>
) : (
visibleNodes.map((node) => {
const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id);
// A shared/group account's header has no mailbox behind it —
// tapping it only opens or closes that account's folders.
const Icon = node.isAccountNode
? Users
: iconFor(node.role, node.name, hasChildren, isExpanded);
const isSelected = !node.isAccountNode && !tagViewActive && node.id === currentMailboxId;
return (
<SidebarRow
key={node.id}
icon={
<Icon
size={16}
color={node.isAccountNode ? c.textMuted : iconColor(c, node.role, isSelected)}
/>
}
label={node.isAccountNode ? node.name : localizeMailboxName(node.role, node.name, t)}
depth={node.depth}
isSelected={isSelected}
isAccountHeader={node.isAccountNode}
unread={node.unreadEmails}
total={node.totalEmails}
showTotal={showFolderTotalCount && !node.isAccountNode}
hasChildren={hasChildren}
isExpanded={isExpanded}
onPress={
node.isAccountNode
? () => toggleExpand(node.id)
: () => handleSelect(node.id)
}
onLongPress={
node.isAccountNode
? () => openSharedAccountSheet(node)
: () => openFolderSheet(node)
}
onToggleExpand={() => toggleExpand(node.id)}
onPressUnread={node.isAccountNode ? undefined : () => handleSelectUnread(node.id)}
/>
);
})
)
)}
{/* Tags section (#175) */}
{keywordDefs.length > 0 && (
<>
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('tagsExpanded', setTagsExpanded)}>
{tagsExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.tags', 'Tags')}</Text>
</Pressable>
{tagsExpanded && keywordDefs.map((kw) => {
const counts = tagCounts[kw.id];
const isSelected = filters.keyword === keywordToken(kw.id);
const dot = c.tags[kw.color]?.dot ?? c.textMuted;
return (
<SidebarRow
key={kw.id}
icon={<Tag size={16} color={dot} fill={dot} />}
label={kw.label}
depth={0}
isSelected={isSelected}
unread={counts?.unread ?? 0}
total={counts?.total ?? 0}
showTotal={showFolderTotalCount}
hasChildren={false}
isExpanded={false}
onPress={() => selectTag(kw.id)}
onToggleExpand={() => {}}
/>
);
})}
</>
)}
</ScrollView>
</SafeAreaView>
);
// On a wide screen the drawer is a permanent sidebar next to the list.
if (docked) {
return (
<View style={styles.docked}>
{panel}
{sheet && <ActionSheet title={sheet.title} actions={sheet.actions} onClose={() => setSheet(null)} />}
{prompt && (
<NamePrompt
title={prompt.title}
message={prompt.message}
initial={prompt.initial}
confirmLabel={prompt.confirmLabel}
onSubmit={prompt.onSubmit}
onClose={() => setPrompt(null)}
/>
)}
</View>
);
}
return (
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
@@ -759,384 +1182,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
</Animated.View>
<Animated.View style={[styles.drawer, { transform: [{ translateX: slideX }] }]}>
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
{/* Header: close + account switcher */}
<View style={styles.header}>
<Pressable
onPress={onClose}
style={styles.headerClose}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => setAccountMenuOpen((v) => !v)}
style={({ pressed }) => [styles.account, pressed && styles.accountPressed]}
>
<View style={[styles.accountAvatar, { backgroundColor: avatarBg }]}>
<Text style={styles.accountAvatarText}>{initials}</Text>
</View>
<View style={styles.accountInfo}>
<Text style={styles.accountName} numberOfLines={1}>{username || t('settings.account.title', 'Account')}</Text>
{hostname ? (
<Text style={styles.accountEmail} numberOfLines={1}>{hostname}</Text>
) : null}
</View>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<ChevronDown
size={16}
color={c.textMuted}
style={accountMenuOpen ? styles.accountChevronOpen : undefined}
/>
)}
</Pressable>
</View>
{accountMenuOpen && (
<View style={styles.accountMenu}>
<ScrollView style={styles.accountMenuList}>
{accounts.map((acc) => {
const isActive = acc.id === activeAccountId;
let accHost = '';
try { accHost = new URL(acc.serverUrl).hostname; } catch { accHost = acc.serverUrl; }
const accEmail = acc.email || (acc.username.includes('@')
? acc.username
: accHost ? `${acc.username}@${accHost}` : acc.username);
return (
<Pressable
key={acc.id}
onPress={() => {
if (isActive) return;
setAccountMenuOpen(false);
onClose();
void switchAccount(acc.id);
}}
onLongPress={isActive ? undefined : () => confirmRemoveAccount(acc)}
delayLongPress={350}
accessibilityHint={isActive ? undefined : t('sidebar.remove_account_hint', 'Long-press to remove this account')}
style={({ pressed }) => [
styles.accountMenuRow,
isActive && styles.accountMenuRowActive,
pressed && !isActive && styles.accountMenuActionPressed,
]}
>
<View style={styles.accountMenuAvatarWrap}>
<View style={[styles.accountMenuAvatar, { backgroundColor: acc.avatarColor }]}>
<Text style={styles.accountMenuAvatarText}>
{getAccountInitials(acc.displayName, acc.email || acc.username)}
</Text>
</View>
{isActive && (
<View style={styles.accountMenuCheckBadge}>
<Check size={10} color={c.primaryForeground} strokeWidth={3} />
</View>
)}
</View>
<View style={styles.accountMenuInfo}>
<View style={styles.accountMenuNameRow}>
<Text style={styles.accountMenuName} numberOfLines={1}>
{acc.displayName || acc.username}
</Text>
{acc.isDefault && (
<Star size={12} color="#f59e0b" fill="#f59e0b" />
)}
</View>
<Text style={styles.accountMenuHost} numberOfLines={1}>{accEmail}</Text>
<View style={styles.accountMenuStatusRow}>
{acc.hasError ? (
<>
<AlertTriangle size={10} color={c.error} />
<Text style={[styles.accountMenuStatusText, { color: c.error }]} numberOfLines={1}>
{acc.errorMessage || t('sidebar.account_error', 'Needs attention')}
</Text>
</>
) : (
<>
{/* The connection dot only means something for the
account the live session serves. */}
{isActive && (
<View style={[
styles.accountMenuStatusDot,
!acc.isConnected && styles.accountMenuStatusDotOffline,
]} />
)}
<Text style={styles.accountMenuStatusText} numberOfLines={1}>{accHost}</Text>
</>
)}
</View>
</View>
{!isActive && (
<Pressable
onPress={() => confirmRemoveAccount(acc)}
hitSlop={8}
style={styles.accountMenuRemove}
accessibilityRole="button"
accessibilityLabel={t('sidebar.remove_account', 'Remove account')}
>
<UserMinus size={14} color={c.textMuted} />
</Pressable>
)}
</Pressable>
);
})}
</ScrollView>
{accounts.length < MAX_ACCOUNTS && (
<>
<View style={styles.accountMenuDivider} />
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => {
setAccountMenuOpen(false);
onClose();
navigation.navigate('AddAccount');
}}
>
<Plus size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>{t('sidebar.add_account', 'Add account')}</Text>
</Pressable>
</>
)}
<View style={styles.accountMenuDivider} />
{accounts.length > 1 && activeAccountId && (() => {
const active = accounts.find((a) => a.id === activeAccountId);
if (!active || active.isDefault) return null;
return (
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => setDefaultAccount(active.id)}
>
<Star size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>{t('sidebar.set_as_default', 'Set as default')}</Text>
</Pressable>
);
})()}
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => { setAccountMenuOpen(false); onClose(); void logout(); }}
>
<LogOut size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText} numberOfLines={1}>
{t('sidebar.sign_out_of', `Sign out of ${accountEmailFull || 'account'}`, { account: accountEmailFull || 'account' })}
</Text>
</Pressable>
{accounts.length > 1 && (
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => { setAccountMenuOpen(false); onClose(); void logoutAll(); }}
>
<LogOut size={16} color={c.error} />
<Text style={[styles.accountMenuActionText, { color: c.error }]}>
{t('sidebar.sign_out_all', 'Sign out of all accounts')}
</Text>
</Pressable>
)}
</View>
)}
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{/* Quick views */}
{showUnified && (
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: 'inbox' }); }}
>
<Layers size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>
{unifiedCrossAccount && accounts.length > 1
? t('sidebar.all_accounts', 'All accounts')
: t('sidebar.unified_inbox', 'All inboxes')}
</Text>
<View style={{ flex: 1 }} />
<RowCounts unread={unifiedCounts.inbox.unread} total={unifiedCounts.inbox.total} showTotal={showFolderTotalCount} />
</Pressable>
)}
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('Scheduled'); }}
>
<Clock size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>{t('sidebar.scheduled', 'Scheduled')}</Text>
</Pressable>
{/* Unified per-role and cross views */}
{showUnified && (
<>
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('unifiedExpanded', setUnifiedExpanded)}>
{unifiedExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.unified_mailbox', 'Unified mailbox')}</Text>
</Pressable>
{unifiedExpanded && (
<>
{([
{ view: 'unread' as const, Icon: MailOpen, label: t('sidebar.unified_all_unread', 'All unread'), unread: unifiedCounts.crossUnread },
{ view: 'starred' as const, Icon: Star, label: t('sidebar.unified_all_starred', 'All starred'), unread: 0 },
{ view: 'all' as const, Icon: Mails, label: t('sidebar.unified_all_mail', 'All mail'), unread: unifiedCounts.crossUnread },
]).map((row) => (
<SidebarRow
key={row.view}
icon={<row.Icon size={16} color={c.textSecondary} />}
label={row.label}
depth={0}
isSelected={false}
unread={row.unread}
total={0}
showTotal={false}
hasChildren={false}
isExpanded={false}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { view: row.view }); }}
onToggleExpand={() => {}}
/>
))}
{unifiedCounts.roles.map((r) => {
const Icon = unifiedIcon(r.role);
return (
<SidebarRow
key={r.role}
icon={<Icon size={16} color={iconColor(c, r.role, false)} />}
label={t(`sidebar.unified_${r.role}`, `All ${r.role}`)}
depth={0}
isSelected={false}
unread={r.unread}
total={r.total}
showTotal={showFolderTotalCount}
hasChildren={false}
isExpanded={false}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: r.role }); }}
onToggleExpand={() => {}}
/>
);
})}
</>
)}
</>
)}
{/* Folders section */}
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}>
{foldersExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.folders', 'Folders')}</Text>
<View style={{ flex: 1 }} />
<Pressable
style={styles.sectionSettings}
hitSlop={8}
onPress={() => { onClose(); navigation.navigate('MainTabs'); }}
accessibilityLabel={t('sidebar.settings', 'Settings')}
>
<Settings size={14} color={c.textMuted} />
</Pressable>
</Pressable>
{foldersExpanded && (
mailboxes.length === 0 ? (
<Text style={styles.empty}>{t('sidebar.loading_mailboxes', 'Loading mailboxes...')}</Text>
) : (
visibleNodes.map((node) => {
const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id);
// A shared/group account's header has no mailbox behind it —
// tapping it only opens or closes that account's folders.
const Icon = node.isAccountNode
? Users
: iconFor(node.role, node.name, hasChildren, isExpanded);
const isSelected = !node.isAccountNode && !tagViewActive && node.id === currentMailboxId;
return (
<SidebarRow
key={node.id}
icon={
<Icon
size={16}
color={node.isAccountNode ? c.textMuted : iconColor(c, node.role, isSelected)}
/>
}
label={node.isAccountNode ? node.name : localizeMailboxName(node.role, node.name, t)}
depth={node.depth}
isSelected={isSelected}
isAccountHeader={node.isAccountNode}
unread={node.unreadEmails}
total={node.totalEmails}
showTotal={showFolderTotalCount && !node.isAccountNode}
hasChildren={hasChildren}
isExpanded={isExpanded}
onPress={
node.isAccountNode
? () => toggleExpand(node.id)
: () => handleSelect(node.id)
}
onLongPress={
node.isAccountNode
? () => openSharedAccountSheet(node)
: () => openFolderSheet(node)
}
onToggleExpand={() => toggleExpand(node.id)}
onPressUnread={node.isAccountNode ? undefined : () => handleSelectUnread(node.id)}
/>
);
})
)
)}
{/* Tags section (#175) */}
{keywordDefs.length > 0 && (
<>
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('tagsExpanded', setTagsExpanded)}>
{tagsExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.tags', 'Tags')}</Text>
</Pressable>
{tagsExpanded && keywordDefs.map((kw) => {
const counts = tagCounts[kw.id];
const isSelected = filters.keyword === keywordToken(kw.id);
const dot = c.tags[kw.color]?.dot ?? c.textMuted;
return (
<SidebarRow
key={kw.id}
icon={<Tag size={16} color={dot} fill={dot} />}
label={kw.label}
depth={0}
isSelected={isSelected}
unread={counts?.unread ?? 0}
total={counts?.total ?? 0}
showTotal={showFolderTotalCount}
hasChildren={false}
isExpanded={false}
onPress={() => selectTag(kw.id)}
onToggleExpand={() => {}}
/>
);
})}
</>
)}
</ScrollView>
</SafeAreaView>
{panel}
</Animated.View>
{sheet && <ActionSheet title={sheet.title} actions={sheet.actions} onClose={() => setSheet(null)} />}
@@ -1154,6 +1200,9 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
);
}
/** Width of the permanent sidebar (the webmail's default sidebar is 256 px). */
export const DOCKED_WIDTH = 272;
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: {
@@ -1173,6 +1222,12 @@ function makeStyles(c: ThemePalette) {
borderRightColor: c.border,
},
drawerSafe: { flex: 1 },
docked: {
width: DOCKED_WIDTH,
backgroundColor: c.secondary,
borderRightWidth: 1,
borderRightColor: c.border,
},
// Header
header: {
@@ -1346,7 +1401,7 @@ function makeStyles(c: ThemePalette) {
...typography.bodySemibold,
color: c.text,
},
sectionSettings: { padding: 4 },
sectionSettings: { padding: 10 },
quickRow: {
flexDirection: 'row',
+10 -6
View File
@@ -4,13 +4,15 @@ import {
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Check, Tag } from 'lucide-react-native';
import { spacing, radius, typography, colors as tokenColors, type ThemePalette } from '../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
import { keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useLocaleStore } from '../stores/locale-store';
import { getEmailTagIds } from '../lib/thread-utils';
import type { Email } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
interface TagSheetProps {
visible: boolean;
@@ -28,6 +30,8 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -65,14 +69,14 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
}, [keywords, selectedEmails]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -83,7 +87,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
<Text style={styles.title}>
{t('context_menu.items_selected', `${selectedEmails.length} emails selected`, { count: selectedEmails.length })}
</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}
<Pressable onPress={onClose} hitSlop={10} style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
@@ -98,7 +102,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
keywords.map((kw) => {
const token = keywordToken(kw.id);
const applied = allHaveToken(token);
const dot = tokenColors.tags[kw.color]?.dot ?? c.primary;
const dot = c.tags[kw.color]?.dot ?? c.primary;
return (
<Pressable
key={kw.id}
@@ -121,7 +125,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
onPress={() => onToggle(token, !applied)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<Tag size={16} color={tokenColors.tags.gray.dot} fill={tokenColors.tags.gray.dot} />
<Tag size={16} color={c.tags.gray.dot} fill={c.tags.gray.dot} />
<Text style={styles.rowLabel} numberOfLines={1}>{id}</Text>
<Text style={styles.rowHint}>{t('email_list.unknown_tag', 'not defined here')}</Text>
{applied && <Check size={16} color={c.primary} />}
+7 -3
View File
@@ -10,6 +10,8 @@ import { useSheetDrag } from '../lib/use-sheet-drag';
import { useLocaleStore } from '../stores/locale-store';
import { useTemplatesStore, type EmailTemplate } from '../stores/templates-store';
import { filterTemplates } from '../lib/template-utils';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
interface TemplateSheetProps {
visible: boolean;
@@ -32,6 +34,8 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
const hydrate = useTemplatesStore((s) => s.hydrate);
const [query, setQuery] = React.useState('');
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -63,14 +67,14 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
}, [templates, query]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -79,7 +83,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('templates.picker_title', 'Choose a Template')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}
<Pressable onPress={onClose} hitSlop={10} style={styles.sheetClose}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
+7 -3
View File
@@ -4,6 +4,7 @@ import {
type AccessibilityActionEvent, type LayoutChangeEvent,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFloatingCardFrame } from '../lib/use-layout';
import { AlertCircle, AlertTriangle, CheckCircle2, Info } from 'lucide-react-native';
import { spacing, radius, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
@@ -21,9 +22,14 @@ import { shouldClaimToastSwipe, toastSwipeRelease } from './toast-swipe';
export function ToastHost(): React.ReactElement | null {
const toasts = useToastStore((s) => s.toasts);
const insets = useSafeAreaInsets();
// Full width above the tab bar on a phone; bottom right on a tablet.
const frame = useFloatingCardFrame();
if (toasts.length === 0) return null;
return (
<View pointerEvents="box-none" style={[styles.wrap, { bottom: insets.bottom + 64 }]}>
<View
pointerEvents="box-none"
style={[styles.wrap, { left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom }]}
>
{toasts.map((t) => <ToastCard key={t.id} toast={t} />)}
</View>
);
@@ -226,8 +232,6 @@ function ToastCard({ toast }: { toast: Toast }) {
const styles = StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
gap: spacing.sm,
zIndex: 60,
},
+2
View File
@@ -29,6 +29,8 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false, a
disabled && styles.disabled,
]}
onPress={() => !disabled && onValueChange(!value)}
// The track is 44 x 24; the touch area reaches 48 dp high.
hitSlop={{ top: 12, bottom: 12, left: 4, right: 4 }}
accessibilityRole="switch"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ checked: value, disabled }}
+4 -5
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFloatingCardFrame } from '../lib/use-layout';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -11,8 +12,6 @@ import { useLocaleStore } from '../stores/locale-store';
import type { RootStackParamList } from '../navigation/types';
const VISIBLE_MS = 5000;
// Clears the tab bar and the viewer's action bar, like the toasts (ToastHost).
const BOTTOM_CLEARANCE = 64;
interface Shown {
label: string;
@@ -32,6 +31,8 @@ export function UndoSnackbar() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
// Full width above the tab bar on a phone; bottom right on a tablet.
const frame = useFloatingCardFrame();
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const entry = useEmailStore((s) => s.pendingUndo);
@@ -128,7 +129,7 @@ export function UndoSnackbar() {
pointerEvents="box-none"
style={[
styles.wrap,
{ bottom: insets.bottom + BOTTOM_CLEARANCE, opacity, transform: [{ translateY: slideY }] },
{ left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom, opacity, transform: [{ translateY: slideY }] },
]}
>
<View style={styles.bar}>
@@ -160,8 +161,6 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
zIndex: 50,
},
bar: {
@@ -17,6 +17,8 @@ import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils';
import Button from '../Button';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
export interface CalendarEditValues {
name: string;
@@ -35,6 +37,8 @@ interface CalendarEditSheetProps {
// Create / rename / recolour / describe a calendar. The same fields the
// webmail's calendar-management settings offer, as a bottom sheet.
export function CalendarEditSheet({ visible, calendar, onSave, onClose }: CalendarEditSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -69,9 +73,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
};
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<Text style={styles.title}>
+5 -1
View File
@@ -1,4 +1,5 @@
import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Animated, Easing, Pressable, StyleSheet, Text, View } from 'react-native';
import { CalendarPlus, CircleCheck, Plus } from 'lucide-react-native';
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -39,6 +40,8 @@ export function CalendarFab({ onNewEvent, onNewTask }: CalendarFabProps) {
action();
};
const insets = useSafeAreaInsets();
const onMainPress = () => {
if (!onNewTask) {
onNewEvent();
@@ -63,7 +66,8 @@ export function CalendarFab({ onNewEvent, onNewTask }: CalendarFabProps) {
/>
</Animated.View>
)}
<View style={styles.anchor} pointerEvents="box-none">
{/* Clear of a side inset (camera cutout, navigation bar) in landscape. */}
<View style={[styles.anchor, { right: spacing.lg + insets.right }]} pointerEvents="box-none">
{open && onNewTask && (
<Animated.View style={[styles.option, { opacity: progress, transform: [{ translateY: lift }] }]}>
<Text style={styles.optionLabel}>{t('calendar.fab.task', 'Task')}</Text>
@@ -17,6 +17,8 @@ import type { Calendar, CalendarRights, Principal } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
type RolePreset = 'freeBusy' | 'read' | 'readWrite' | 'manager';
@@ -69,6 +71,8 @@ interface CalendarShareSheetProps {
// principal list comes from the same Principal/query the Files share sheet
// uses.
export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShareSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -155,9 +159,9 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
};
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<View style={styles.titleRow}>
@@ -23,6 +23,7 @@ import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
import { isWritableCalendar } from '../../lib/calendar-editability';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
/** A calendar view listed at the top of the drawer. */
export interface DrawerViewOption {
@@ -151,7 +152,7 @@ export function CalendarSidebarDrawer({
};
return (
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
+16 -9
View File
@@ -57,9 +57,12 @@ import {
import { getEventEditability } from '../../lib/calendar-editability';
import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDate } from '../../lib/date-format';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrence';
import type { TranslateFn } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
@@ -93,9 +96,9 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate()
) {
return format(start, 'EEEE, MMM d, yyyy', opts);
return formatCalendarDate(start, locale.code, 'longWeekdayMonthDayYear');
}
return `${format(start, 'MMM d', opts)} – ${format(end, 'MMM d, yyyy', opts)}`;
return `${formatCalendarDate(start, locale.code, 'monthDay')} – ${formatCalendarDate(end, locale.code, 'monthDayYear')}`;
}
const tp = timePattern(timeFormat);
const sameDay =
@@ -103,9 +106,9 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate();
if (sameDay) {
return `${format(start, 'EEE, MMM d', opts)} · ${format(start, tp, opts)} – ${format(end, tp, opts)}`;
return `${formatCalendarDate(start, locale.code, 'weekdayMonthDay')} · ${format(start, tp, opts)} – ${format(end, tp, opts)}`;
}
return `${format(start, `MMM d, ${tp}`, opts)} – ${format(end, `MMM d, ${tp}`, opts)}`;
return `${formatCalendarDate(start, locale.code, 'monthDay')}, ${format(start, tp, opts)} – ${formatCalendarDate(end, locale.code, 'monthDay')}, ${format(end, tp, opts)}`;
}
function recurrenceLabel(event: CalendarEvent, t: TranslateFn, locale: Locale): string | null {
@@ -140,6 +143,8 @@ export function EventDetailSheet({
// name) render with the contact card's name instead of the raw email.
const resolveContactName = useContactNameResolver();
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const visible = !!event;
const dragHandlers = useSheetDrag({
@@ -243,7 +248,7 @@ export function EventDetailSheet({
};
return (
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
@@ -254,7 +259,7 @@ export function EventDetailSheet({
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
@@ -272,7 +277,7 @@ export function EventDetailSheet({
<Pressable
onPress={onClose}
style={styles.closeBtn}
hitSlop={8}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
@@ -613,7 +618,9 @@ function makeStyles(c: ThemePalette) {
borderTopWidth: 1,
borderTopColor: c.border,
},
sheetSafe: { paddingTop: spacing.sm },
// The body shrinks to what is left under the sheet's max height, so the
// action row stays on screen in a short (landscape) window.
sheetSafe: { paddingTop: spacing.sm, flexShrink: 1 },
handleHit: {
alignItems: 'center',
paddingTop: spacing.xs,
@@ -640,7 +647,7 @@ function makeStyles(c: ThemePalette) {
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
closeBtn: { padding: 4 },
body: { maxHeight: 400 },
body: { flexShrink: 1 },
bodyContent: {
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
+12 -3
View File
@@ -9,6 +9,7 @@ import {
StyleSheet,
Switch,
Platform,
Alert as NativeAlert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import DateTimePicker from '@react-native-community/datetimepicker';
@@ -46,6 +47,7 @@ import { editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-edi
import { useSettingsStore } from '../../stores/settings-store';
import { canCreateEventsIn } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDate } from '../../lib/date-format';
import {
alertsToReminders,
remindersToAlerts,
@@ -67,6 +69,7 @@ import { Button } from '..';
import { ParticipantInput } from './ParticipantInput';
import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { RecurrenceEditor } from './RecurrenceEditor';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
@@ -363,6 +366,12 @@ export function EventModal({
sendSchedulingMessages: attendees.length > 0 && sendInvitations,
});
onClose();
} catch (err) {
// The editor stays open with everything typed, and says why.
NativeAlert.alert(
t('calendar.notifications.event_error', 'Failed to save event'),
err instanceof Error ? err.message : undefined,
);
} finally {
setSaving(false);
}
@@ -410,7 +419,7 @@ export function EventModal({
);
return (
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
animationType="slide"
presentationStyle="fullScreen"
@@ -522,7 +531,7 @@ export function EventModal({
style={styles.dateBtn}
onPress={() => setShowStartDate(true)}
>
<Text style={styles.dateText}>{format(start, 'EEE, MMM d', { locale })}</Text>
<Text style={styles.dateText}>{formatCalendarDate(start, locale.code, 'weekdayMonthDay')}</Text>
</Pressable>
{!allDay && (
<Pressable
@@ -542,7 +551,7 @@ export function EventModal({
style={styles.dateBtn}
onPress={() => setShowEndDate(true)}
>
<Text style={styles.dateText}>{format(end, 'EEE, MMM d', { locale })}</Text>
<Text style={styles.dateText}>{formatCalendarDate(end, locale.code, 'weekdayMonthDay')}</Text>
</Pressable>
{!allDay && (
<Pressable
+6 -2
View File
@@ -17,6 +17,8 @@ import { ImportRefusedError, type ImportResult } from '../../stores/calendar-sto
import { useLocaleStore } from '../../stores/locale-store';
import { useCalendarLocale } from '../../lib/calendar-locale';
import type { Locale } from 'date-fns';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
@@ -74,6 +76,8 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
const [result, setResult] = React.useState<ImportResult | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const resetState = React.useCallback(() => {
@@ -184,11 +188,11 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.title}>{t('calendar.import.title', 'Import Calendar')}</Text>
@@ -1,6 +1,6 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator,
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator, Alert,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from 'lucide-react-native';
@@ -17,6 +17,8 @@ import {
type CalendarSubscription,
} from '../../stores/calendar-subscriptions-store';
import { jmapClient } from '../../api/jmap-client';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
// The webmail's refresh intervals and their labels.
const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [
@@ -75,6 +77,8 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
};
const slideY = React.useRef(new Animated.Value(900)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
@@ -131,11 +135,11 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Rss size={20} color={c.text} />
@@ -275,7 +279,24 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
)}
</Pressable>
<Pressable
onPress={() => { void removeSubscription(sub.id); }}
// Unsubscribing also deletes the calendar and its events.
onPress={() => Alert.alert(
t('calendar.subscription.unsubscribe', 'Unsubscribe'),
t('calendar.subscription.confirm_delete', 'Unsubscribe from "{name}"? The calendar and all its events will be removed.', { name: sub.name }),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{
text: t('calendar.subscription.unsubscribe', 'Unsubscribe'),
style: 'destructive',
onPress: () => {
removeSubscription(sub.id).catch((err: unknown) => Alert.alert(
t('calendar.subscription.delete_error', 'Failed to remove subscription'),
err instanceof Error ? err.message : undefined,
));
},
},
],
)}
hitSlop={6}
style={styles.subBtn}
accessibilityRole="button"
+26 -7
View File
@@ -1,6 +1,7 @@
import React from 'react';
import {
FlatList,
ScrollView,
StyleSheet,
View,
useWindowDimensions,
@@ -49,7 +50,13 @@ export function MonthDropdown({
}: MonthDropdownProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { width } = useWindowDimensions();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
// Pages are as wide as the panel (the window, or a pane on a tablet) and
// re-measure on rotation. In a short window the panel takes at most about
// half of it and its month scrolls inside.
const [measuredWidth, setMeasuredWidth] = React.useState(0);
const width = measuredWidth || windowWidth;
const panelHeight = Math.min(PANEL_HEIGHT, Math.round(windowHeight * 0.55));
// The months are fixed while the panel is open; it remounts on each open.
const [months] = React.useState(() => {
const first = addMonths(startOfMonth(month), -MONTHS_EACH_SIDE);
@@ -69,7 +76,11 @@ export function MonthDropdown({
const renderItem = React.useCallback(
({ item }: ListRenderItemInfo<Date>) => (
<View style={{ width, height: PANEL_HEIGHT }}>
<ScrollView
style={{ width, height: panelHeight }}
scrollEnabled={panelHeight < PANEL_HEIGHT}
showsVerticalScrollIndicator={false}
>
<MonthView
currentDate={item}
selectedDate={selectedDate}
@@ -79,9 +90,9 @@ export function MonthDropdown({
weekStartsOn={weekStartsOn}
onSelectDate={onSelectDate}
/>
</View>
</ScrollView>
),
[width, selectedDate, eventsByDay, calendars, weekStartsOn, onSelectDate],
[width, panelHeight, selectedDate, eventsByDay, calendars, weekStartsOn, onSelectDate],
);
const getItemLayout = React.useCallback(
@@ -90,20 +101,28 @@ export function MonthDropdown({
);
return (
<View style={styles.panel}>
<View
style={styles.panel}
onLayout={(e) => {
const w = Math.round(e.nativeEvent.layout.width);
if (w > 0 && w !== measuredWidth) setMeasuredWidth(w);
}}
>
<FlatList
// A new width remounts the pager on the month it was showing.
key={width}
horizontal
pagingEnabled
data={months}
keyExtractor={monthKey}
renderItem={renderItem}
getItemLayout={getItemLayout}
initialScrollIndex={MONTHS_EACH_SIDE}
initialScrollIndex={lastIndexRef.current}
onMomentumScrollEnd={handleMomentumEnd}
showsHorizontalScrollIndicator={false}
initialNumToRender={1}
windowSize={3}
style={{ height: PANEL_HEIGHT }}
style={{ height: panelHeight }}
/>
</View>
);
+33 -9
View File
@@ -18,6 +18,7 @@ import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window';
import {
dayIndexIn,
monthFocusRow,
monthRowGeometry,
monthKeyOf,
monthMask,
sampledRow,
@@ -124,8 +125,9 @@ function MonthScrollViewInner({
const height = e.nativeEvent.layout.height;
if (height > 0) setListHeight(height);
}, []);
const rowHeight = Math.max(MONTH_ROW_HEIGHT_FULL_MIN, Math.floor(listHeight / VISIBLE_ROWS));
const viewportHeight = rowHeight * VISIBLE_ROWS;
// A phone in landscape has room for three or four weeks, not six.
const { rowHeight, visibleRows } = monthRowGeometry(listHeight, MONTH_ROW_HEIGHT_FULL_MIN, VISIBLE_ROWS);
const viewportHeight = listHeight;
// The month in focus: set by navigation, then by what scrolls under the
// sample line.
@@ -134,16 +136,28 @@ function MonthScrollViewInner({
const onVisibleRef = React.useRef(onVisibleDateChange);
onVisibleRef.current = onVisibleDateChange;
const offsetRef = React.useRef(0);
// Row at the top of the viewport; a change of row height (the "show time"
// setting) remounts the list there.
const topRowRef = React.useRef<number | null>(null);
// Week at the top of the viewport; a change of row height (the "show time"
// setting, a rotation) remounts the list there. Kept as the week, not its
// index, because weeks added above shift the indices.
const topWeekRef = React.useRef<string | null>(null);
// A (re)mounted list reports offset 0 before its initial scroll lands; that
// report would set the month to the window's first one (a rotation jumped
// back a month). Until the list reaches its start row, or the user drags,
// scroll reports don't move the month.
const settledRef = React.useRef(false);
const handleScroll = React.useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const offset = e.nativeEvent.contentOffset.y;
if (!settledRef.current) {
const expected = (initialRef.current?.row ?? 0) * rowHeight;
if (Math.abs(offset - expected) > rowHeight / 2) return;
settledRef.current = true;
}
offsetRef.current = offset;
topRowRef.current = Math.round(offset / rowHeight);
const list = weeksRef.current;
const top = list[Math.max(0, Math.min(list.length - 1, Math.round(offset / rowHeight)))];
if (top) topWeekRef.current = dayKey(top[0]);
const row = sampledRow(offset, viewportHeight, rowHeight, list.length);
const days = list[row];
if (!days) return;
@@ -158,15 +172,18 @@ function MonthScrollViewInner({
[viewportHeight, rowHeight],
);
const markSettled = React.useCallback(() => { settledRef.current = true; }, []);
// Navigation inside the window scrolls the focused month's first week to
// the top. The list mounts there already, so its own nonce needs nothing.
const listRef = React.useRef<FlatList<Date[]>>(null);
const handledNonceRef = React.useRef(focus.nonce);
const initialRef = React.useRef<{ rowHeight: number; row: number } | null>(null);
if (initialRef.current === null || initialRef.current.rowHeight !== rowHeight) {
settledRef.current = false;
initialRef.current = {
rowHeight,
row: topRowRef.current ?? monthFocusRow(window, focus.date, weeks.length, opts),
row: rowOfWeek(weeks, topWeekRef.current) ?? monthFocusRow(window, focus.date, weeks.length, opts),
};
}
React.useEffect(() => {
@@ -263,12 +280,13 @@ function MonthScrollViewInner({
onEndReached={onExtendEnd}
onEndReachedThreshold={0.5}
onScroll={handleScroll}
onScrollBeginDrag={markSettled}
scrollEventThrottle={32}
snapToInterval={rowHeight}
decelerationRate="fast"
showsVerticalScrollIndicator={false}
initialNumToRender={VISIBLE_ROWS + 2}
maxToRenderPerBatch={VISIBLE_ROWS}
initialNumToRender={visibleRows + 2}
maxToRenderPerBatch={visibleRows}
windowSize={5}
/>
)}
@@ -282,3 +300,9 @@ export const MonthScrollView = React.memo(MonthScrollViewInner);
function weekKey(days: Date[]): string {
return dayKey(days[0]);
}
function rowOfWeek(weeks: Date[][], key: string | null): number | null {
if (!key) return null;
const i = weeks.findIndex((days) => dayKey(days[0]) === key);
return i >= 0 ? i : null;
}
+14 -5
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { View, Text, StyleSheet, Pressable, useWindowDimensions } from 'react-native';
import {
addDays,
endOfMonth,
@@ -24,7 +24,7 @@ import {
type EventDayIndex,
type TimeFormat,
} from '../../lib/calendar-utils';
import { dayIndexIn, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll';
import { dayIndexIn, monthChipShowsTime, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll';
import { isInactiveEvent } from '../../lib/calendar-participants';
import { eventBlockColors } from '../../lib/event-colors';
import { useCalendarLocale } from '../../lib/calendar-locale';
@@ -38,6 +38,8 @@ export const MONTH_ROW_HEIGHT = 54;
export const MONTH_ROW_HEIGHT_CHIPS = 74;
// Smallest week row of the full-screen month grid.
export const MONTH_ROW_HEIGHT_FULL_MIN = 72;
/** Smallest row of the paged month grid, which can't scroll to hidden weeks. */
const MONTH_ROW_HEIGHT_FILL_MIN = 36;
// Corner radius of event bars (repos/branding/APP.md).
const EVENT_RADIUS = 2;
@@ -151,6 +153,8 @@ function MonthWeekRowInner({
onLongPressDate,
}: MonthWeekRowProps) {
const c = useColors();
const { width } = useWindowDimensions();
const chipTime = monthChipShowsTime(true, width / 7);
if (variant === 'full') {
return (
<FullWeekRow
@@ -256,7 +260,7 @@ function MonthWeekRowInner({
]}
>
<Text style={[styles.chipText, { color: colors.text }]} numberOfLines={1}>
{event.showWithoutTime
{event.showWithoutTime || !chipTime
? null
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `}
<Text style={inactive && styles.chipTextInactive}>{event.title || ''}</Text>
@@ -316,6 +320,8 @@ function FullWeekRow({
height: number;
colors: ThemePalette;
}) {
const { width } = useWindowDimensions();
const showTime = monthChipShowsTime(showTimeInMonthView, width / 7);
return (
<View style={[styles.fullWeekRow, { height }]}>
{showWeekNumbers && (
@@ -355,7 +361,7 @@ function FullWeekRow({
const inactive = isInactiveEvent(event, currentUserEmails);
const colors = eventBlockColors(getEventColor(event, calendars), inactive, c);
// No time on events that fill the whole day.
const time = showTimeInMonthView && !event.showWithoutTime && !isTimedEventFullDayOnDate(event, d)
const time = showTime && !event.showWithoutTime && !isTimedEventFullDayOnDate(event, d)
? `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `
: '';
return (
@@ -424,8 +430,11 @@ function MonthViewInner({
// Today on a clock in the calendar's time zone.
const today = displayNow();
// Every week of the month has to show (there is no scrolling here), so in a
// short window (phone landscape) rows shrink below the usual minimum and
// their chips collapse into "+N".
const fullRowHeight = fill && gridHeight > 0
? Math.max(MONTH_ROW_HEIGHT_FULL_MIN, Math.floor(gridHeight / rows.length))
? Math.max(MONTH_ROW_HEIGHT_FILL_MIN, Math.floor(gridHeight / rows.length))
: undefined;
return (
@@ -107,6 +107,9 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
)}
<TextInput
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
value={draft}
onChangeText={setDraft}
placeholder={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
@@ -5,6 +5,7 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { Button } from '..';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all';
@@ -42,7 +43,7 @@ export function RecurrenceScopeDialog({
}, [visible]);
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={styles.backdrop}>
<View style={styles.dialog}>
<View style={styles.header}>
+11 -6
View File
@@ -41,7 +41,10 @@ import {
} from '../../lib/task-editor';
import { isWritableCalendar } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDate } from '../../lib/date-format';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
type TaskFilter = 'all' | 'pending' | 'completed' | 'overdue';
@@ -107,6 +110,8 @@ export function TasksSheet({
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({
slideY,
@@ -209,8 +214,8 @@ export function TasksSheet({
if (!d || !task.due) return null;
const hasTime = !task.showWithoutTime && !/^\d{4}-\d{2}-\d{2}$/.test(task.due);
return hasTime
? format(d, `EEE, MMM d · ${timePattern(timeFormat)}`, { locale })
: format(d, 'EEE, MMM d', { locale });
? `${formatCalendarDate(d, locale.code, 'weekdayMonthDay')} · ${format(d, timePattern(timeFormat), { locale })}`
: formatCalendarDate(d, locale.code, 'weekdayMonthDay');
};
const priorityColor = (level: PriorityLevel): string =>
@@ -230,12 +235,12 @@ export function TasksSheet({
];
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
@@ -247,7 +252,7 @@ export function TasksSheet({
<Pressable
onPress={onClose}
style={styles.closeBtn}
hitSlop={8}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
@@ -295,7 +300,7 @@ export function TasksSheet({
>
<CalendarIcon size={14} color={editor.due ? c.primary : c.textMuted} />
<Text style={[styles.chipText, editor.due && styles.chipTextActive]}>
{editor.due ? format(editor.due, 'MMM d', { locale }) : t('calendar.tasks.due_date', 'Due date')}
{editor.due ? formatCalendarDate(editor.due, locale.code, 'monthDay') : t('calendar.tasks.due_date', 'Due date')}
</Text>
{editor.due && (
<Pressable
+28 -2
View File
@@ -61,6 +61,8 @@ type TimeFormat = '12h' | '24h';
interface TimeGridScrollViewProps {
mode: TimeGridMode;
/** Days of the multi-day mode (`multiDayCount`). */
multiDays?: number;
/** The day the user navigated to; its week (week view) or itself (day view) is scrolled into view. */
focus: CalendarFocus;
/** The loaded window: one column per day. */
@@ -96,6 +98,7 @@ interface TimeGridScrollViewProps {
*/
function TimeGridScrollViewInner({
mode,
multiDays = 3,
focus,
window,
selectedDate,
@@ -120,7 +123,7 @@ function TimeGridScrollViewInner({
[eventsByDay, events],
);
const perScreen = columnsPerScreen(mode);
const perScreen = columnsPerScreen(mode, multiDays);
const { width: screenWidth } = useWindowDimensions();
const [rootWidth, setRootWidth] = React.useState(screenWidth);
const listWidth = Math.max(perScreen, rootWidth - GUTTER_WIDTH);
@@ -217,6 +220,7 @@ function TimeGridScrollViewInner({
listener: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const list = daysRef.current;
offsetXRef.current = e.nativeEvent.contentOffset.x;
if (e.nativeEvent.contentOffset.x > 0) scrolledSinceMountRef.current = true;
const col = Math.max(
0,
Math.min(list.length - 1, Math.round(e.nativeEvent.contentOffset.x / colWidthRef.current)),
@@ -239,7 +243,12 @@ function TimeGridScrollViewInner({
const visibleHandlerRef = React.useRef(onVisibleDateChange);
visibleHandlerRef.current = onVisibleDateChange;
const lastVisibleKeyRef = React.useRef<string | null>(null);
// Right after a (re)mount the list reports its first days as visible
// before the initial scroll lands; that would set the title to the window's
// first month. Reports wait until the list has scrolled at all.
const scrolledSinceMountRef = React.useRef(false);
const onViewableItemsChanged = React.useRef(({ viewableItems }: { viewableItems: ViewToken[] }) => {
if (!scrolledSinceMountRef.current) return;
let first: Date | null = null;
for (const token of viewableItems) {
const day = token.item as Date;
@@ -379,6 +388,21 @@ function TimeGridScrollViewInner({
),
};
}
// The remounted list starts at that day, but the header strip still holds
// the old pixel offset until the list scrolls: move it now, or the header
// shows other days (or none) after a rotation.
React.useLayoutEffect(() => {
const index = initialIndexRef.current?.index ?? 0;
scrolledSinceMountRef.current = false;
offsetXRef.current = index * colWidth;
scrollX.setValue(offsetXRef.current);
const day = days[index];
if (day) {
headerAnchorRef.current = day;
setHeaderAnchor(day);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listKey]);
const listStyle = React.useMemo(() => ({ width: listWidth, height: GRID_HEIGHT }), [listWidth]);
return (
@@ -388,7 +412,9 @@ function TimeGridScrollViewInner({
<View style={{ height: HEADER_HEIGHT }} />
{allDayHeight > 0 && (
<View style={[styles.allDayLabelWrap, { height: allDayHeight }]}>
<Text style={styles.allDayLabel} numberOfLines={2}>
{/* One line that shrinks to the gutter: a second line was cut
off by a one-row strip, leaving "All". */}
<Text style={styles.allDayLabel} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.6}>
{t('calendar.events.all_day', 'All day')}
</Text>
</View>
+1 -1
View File
@@ -179,7 +179,7 @@ function WeekViewInner({
{allDayRowCount > 0 && (
<View style={[styles.allDayStrip, { height: allDayStripHeight }]}>
<View style={[styles.gutter, styles.allDayLabelWrap]}>
<Text style={styles.allDayLabel} numberOfLines={2}>{t('calendar.events.all_day', 'All day')}</Text>
<Text style={styles.allDayLabel} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.6}>{t('calendar.events.all_day', 'All day')}</Text>
</View>
<View style={styles.allDayGrid}>
{/* Empty per-day columns to draw vertical separators */}
@@ -9,6 +9,8 @@ import { useColors } from '../../theme/colors';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useContactsStore } from '../../stores/contacts-store';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
interface Props {
visible: boolean;
@@ -34,6 +36,8 @@ export default function AddressBookPickerSheet({
const [busy, setBusy] = React.useState(false);
const slideY = React.useRef(new Animated.Value(600)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
@@ -70,14 +74,14 @@ export default function AddressBookPickerSheet({
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -88,7 +92,7 @@ export default function AddressBookPickerSheet({
<Text style={styles.title}>{title ?? t('contacts.bulk.move_to_address_book', 'Move to address book')}</Text>
<Pressable
onPress={onClose}
hitSlop={8}
hitSlop={10}
style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
@@ -281,6 +281,9 @@ function makeStyles(c: ThemePalette) {
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
sectionIcon: { width: 16 },
sectionLabel: {
// Fill the row: shrink-wrapped, Android measured the letter-spaced
// label a few px short and wrapped its last word out of sight ("WOR").
flex: 1,
...typography.bodyMedium,
color: c.textSecondary,
textTransform: 'uppercase',
@@ -13,6 +13,8 @@ import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen upload flow).
@@ -56,6 +58,8 @@ export default function ContactImportSheet({
const [result, setResult] = React.useState<{ imported: number; failed: number } | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const resetState = React.useCallback(() => {
@@ -160,11 +164,11 @@ export default function ContactImportSheet({
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.title}>{t('contacts.import.title', 'Import Contacts')}</Text>
@@ -19,6 +19,8 @@ interface ContactListRowProps {
onPress?: () => void;
onLongPress?: () => void;
selected?: boolean;
/** The contact open in the detail pane of a split view. */
active?: boolean;
selectionMode?: boolean;
secondaryText?: string;
}
@@ -28,6 +30,7 @@ export default function ContactListRow({
onPress,
onLongPress,
selected,
active = false,
selectionMode,
secondaryText,
}: ContactListRowProps) {
@@ -52,6 +55,7 @@ export default function ContactListRow({
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
active && styles.rowActive,
selected && styles.rowSelected,
]}
>
@@ -95,6 +99,13 @@ function makeStyles(c: ThemePalette) {
},
rowPressed: { backgroundColor: c.surfaceHover },
rowSelected: { backgroundColor: c.primaryBg },
// Same as the open message in the mail list (webmail bg-selection + bar).
rowActive: {
backgroundColor: c.selection,
borderLeftWidth: 2,
borderLeftColor: c.primary,
paddingLeft: spacing.lg - 2,
},
checkbox: {
width: 22,
height: 22,
@@ -16,6 +16,8 @@ import { useSheetDrag } from '../../lib/use-sheet-drag';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
interface Props {
visible: boolean;
@@ -41,6 +43,8 @@ export default function ContactPickerSheet({
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlay = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({
slideY,
@@ -94,12 +98,12 @@ export default function ContactPickerSheet({
};
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView style={{ flex: 1 }} edges={['bottom']}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
@@ -18,6 +18,7 @@ import { getContactDisplayName, getContactKeywords, isGroup } from '../../lib/co
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
type Nav = NativeStackNavigationProp<RootStackParamList>;
@@ -161,7 +162,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
);
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
+7 -3
View File
@@ -9,6 +9,8 @@ import { useColors } from '../../theme/colors';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useContactsStore, selectKeywordsUsed } from '../../stores/contacts-store';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
interface Props {
visible: boolean;
@@ -35,6 +37,8 @@ export default function TagAssignSheet({
const [input, setInput] = React.useState('');
const slideY = React.useRef(new Animated.Value(600)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
@@ -60,14 +64,14 @@ export default function TagAssignSheet({
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -78,7 +82,7 @@ export default function TagAssignSheet({
<Text style={styles.title}>{title ?? t('contacts.bulk.add_tag', 'Add tag')}</Text>
<Pressable
onPress={onClose}
hitSlop={8}
hitSlop={10}
style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
+7 -3
View File
@@ -6,6 +6,8 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
export interface ActionSheetItem {
key: string;
@@ -37,6 +39,8 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children
const insets = useSafeAreaInsets();
const t = useLocaleStore((s) => s.t);
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -55,14 +59,14 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children
}, [visible, slideY, overlayOpacity]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -74,7 +78,7 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children
<Text style={styles.title} numberOfLines={1}>{title}</Text>
{subtitle ? <Text style={styles.subtitle} numberOfLines={1}>{subtitle}</Text> : null}
</View>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}
<Pressable onPress={onClose} hitSlop={10} style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
@@ -12,6 +12,7 @@ import { useLocaleStore } from '../../stores/locale-store';
import EmailBodyView from '../EmailBodyView';
import type { PreviewKind } from '../../lib/attachment-display';
import type { EmlPreview } from './use-body-override';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
export interface PreviewItem {
kind: PreviewKind;
@@ -38,6 +39,8 @@ interface Props {
onShare: () => void;
/** Save a copy; adds a Download button to the header. */
onDownload?: () => void;
/** Render in place (a split view's side pane) instead of a full-screen modal. */
docked?: boolean;
}
/**
@@ -47,7 +50,7 @@ interface Props {
* Used by the reader's attachment chips and the Files tab.
*/
export function AttachmentPreviewModal({
item, loading, title, onClose, onOpenExternal, onShare, onDownload,
item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false,
}: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -56,6 +59,9 @@ export function AttachmentPreviewModal({
// An image the platform can't decode would stay blank; fall back to the
// external viewer instead. Keyed by URI so the next item starts clean.
const [failedImageUri, setFailedImageUri] = React.useState<string | null>(null);
// The image fits the area it is shown in (a pane, a landscape window),
// not a box derived from the window width that ran past the screen.
const [imageArea, setImageArea] = React.useState<{ width: number; height: number } | null>(null);
const emlEmail = React.useMemo<Email | null>(() => {
if (!item?.eml) return null;
@@ -103,13 +109,19 @@ export function AttachmentPreviewModal({
<ScrollView
style={styles.flex}
contentContainerStyle={styles.imageScroll}
onLayout={(e) => {
const { width: w, height: h } = e.nativeEvent.layout;
setImageArea((prev) => (prev && prev.width === w && prev.height === h ? prev : { width: w, height: h }));
}}
maximumZoomScale={4}
minimumZoomScale={1}
bouncesZoom
>
<Image
source={{ uri: item.fileUri }}
style={{ width: width - spacing.lg * 2, height: width * 1.2 }}
style={imageArea
? { width: imageArea.width - spacing.lg * 2, height: imageArea.height - spacing.lg * 2 }
: { width: width - spacing.lg * 2, height: width * 1.2 }}
resizeMode="contain"
accessibilityLabel={item.name}
onError={() => setFailedImageUri(item.fileUri ?? null)}
@@ -159,9 +171,8 @@ export function AttachmentPreviewModal({
}
};
return (
<Modal visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
const content = (
<SafeAreaView style={styles.container} edges={docked ? ['top', 'right'] : ['top', 'bottom', 'left', 'right']}>
<View style={styles.header}>
<Pressable
onPress={onClose}
@@ -209,6 +220,11 @@ export function AttachmentPreviewModal({
</View>
{renderBody()}
</SafeAreaView>
);
if (docked) return item || loading ? content : null;
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
{content}
</Modal>
);
}
+7 -1
View File
@@ -147,6 +147,9 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
return (
<View style={styles.box}>
<TextInput
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
value={text}
onChangeText={setText}
onFocus={onFocus}
@@ -158,7 +161,7 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
editable={!sending}
/>
<View style={styles.actions}>
<Pressable style={styles.more} onPress={() => onMoreOptions(text)} hitSlop={6}>
<Pressable style={styles.more} onPress={() => onMoreOptions(text)} hitSlop={10} accessibilityRole="button">
<Maximize2 size={14} color={c.textSecondary} />
<Text style={styles.moreText}>{t('email_viewer.more_options', 'More options')}</Text>
</Pressable>
@@ -166,6 +169,9 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
style={[styles.send, (!text.trim() || sending) && styles.sendDisabled]}
onPress={send}
disabled={!text.trim() || sending}
hitSlop={10}
accessibilityRole="button"
accessibilityState={{ disabled: !text.trim() || sending, busy: sending }}
>
{sending ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Send size={14} color={c.primaryForeground} />}
<Text style={styles.sendText}>{sending ? t('email_viewer.sending', 'Sending...') : t('email_viewer.send', 'Send')}</Text>
+3 -3
View File
@@ -74,15 +74,15 @@ export function ThreadMessageCard({
<MessageContent {...content} compact={false} onToggleStar={bare ? undefined : onToggleStar} />
{!bare && (
<View style={[styles.actions, !replyable && styles.actionsDisabled]}>
<Pressable style={styles.actionBtn} onPress={reply('reply')} hitSlop={4}>
<Pressable style={styles.actionBtn} onPress={reply('reply')} hitSlop={9}>
<Reply size={16} color={c.textSecondary} />
<Text style={styles.actionLabel}>{t('email_viewer.reply', 'Reply')}</Text>
</Pressable>
<Pressable style={styles.actionBtn} onPress={reply('replyAll')} hitSlop={4}>
<Pressable style={styles.actionBtn} onPress={reply('replyAll')} hitSlop={9}>
<ReplyAll size={16} color={c.textSecondary} />
<Text style={styles.actionLabel}>{t('email_viewer.reply_all', 'Reply All')}</Text>
</Pressable>
<Pressable style={styles.actionBtn} onPress={reply('forward')} hitSlop={4}>
<Pressable style={styles.actionBtn} onPress={reply('forward')} hitSlop={9}>
<Forward size={16} color={c.textSecondary} />
<Text style={styles.actionLabel}>{t('email_viewer.forward', 'Forward')}</Text>
</Pressable>
+4 -1
View File
@@ -25,6 +25,8 @@ interface Props {
/** The file to show; null closes the preview. */
file: PreviewableFileNode | null;
onClose: () => void;
/** Show it in a side pane instead of a full-screen modal. */
docked?: boolean;
}
/**
@@ -35,7 +37,7 @@ interface Props {
* of fetching it again. The copy is deleted when the preview closes, unless
* another app got it, in which case the stale-export sweep removes it.
*/
export function FilePreviewModal({ file, onClose }: Props) {
export function FilePreviewModal({ file, onClose, docked = false }: Props) {
const t = useLocaleStore((s) => s.t);
const [item, setItem] = React.useState<PreviewItem | null>(null);
const [loading, setLoading] = React.useState(false);
@@ -123,6 +125,7 @@ export function FilePreviewModal({ file, onClose }: Props) {
item={item}
loading={loading}
title={name}
docked={docked}
onClose={onClose}
onOpenExternal={() => act(
(local) => shareLocalFile(local, type, name, { keep: true }),
+20 -3
View File
@@ -18,6 +18,8 @@ import type { FileNode, FileNodeRights, Principal } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
type RolePreset = 'read' | 'readWrite' | 'manager';
@@ -65,6 +67,8 @@ interface ShareSheetProps {
}
export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -155,9 +159,9 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
};
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<View style={styles.titleRow}>
@@ -210,8 +214,21 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Pressable
onPress={() => void applyShare(principalId, null)}
// Revoking access is immediate on the server: ask first.
onPress={() => Alert.alert(
t('files.share_remove_confirm', 'Remove access?'),
undefined,
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{
text: t('files.share_remove', 'Remove access'),
style: 'destructive',
onPress: () => { void applyShare(principalId, null); },
},
],
)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('files.share_remove', 'Remove access')}
>
<Trash2 size={18} color={c.error} />
+2 -1
View File
@@ -37,6 +37,7 @@ import type {
FilterComparator,
FilterActionType,
} from '../../lib/sieve/types';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment'];
const TEXT_COMPARATORS: FilterComparator[] = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'];
@@ -226,7 +227,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
}, [name, conditions, actions, matchType, stopProcessing, includeSpam, rule, onSave, t, mailboxTargets]);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable
+7 -4
View File
@@ -9,6 +9,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
import { useColors } from '../../theme/colors';
import Button from '../Button';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
interface SieveEditorSheetProps {
visible: boolean;
@@ -62,7 +63,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
}, [script, showSaveWarning, onSave]);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable
@@ -205,11 +206,13 @@ function makeStyles(c: ThemePalette) {
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: 'rgba(252, 211, 77, 0.4)',
backgroundColor: 'rgba(120, 53, 15, 0.25)',
// Theme warning tokens: the fixed dark-amber values left light-yellow
// text on a near-white fill in the light theme.
borderColor: c.warning,
backgroundColor: c.warningBg,
alignItems: 'flex-start',
},
warningText: { ...typography.caption, color: '#fde68a', flex: 1 },
warningText: { ...typography.caption, color: c.text, flex: 1 },
editorWrap: {
flex: 1,
+2 -2
View File
@@ -5,7 +5,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ArrowDown, ArrowUp, Check, ChevronRight, Plus, Star, Trash2, Users } from 'lucide-react-native';
import { SettingsSection, SettingItem } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAuthStore } from '../../stores/auth-store';
import { useAccountStore } from '../../stores/account-store';
@@ -341,7 +341,7 @@ function makeStyles(c: ThemePalette) {
rowError: { ...typography.caption, color: c.error },
defaultBadge: { ...typography.caption, color: c.primary, marginTop: 2 },
rowActions: { flexDirection: 'row', gap: 2 },
iconBtn: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
iconBtn: { width: componentSizes.buttonLg, height: componentSizes.buttonLg, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
addRow: { gap: spacing.sm, alignItems: 'flex-start' },
hint: { ...typography.caption, color: c.mutedForeground },
});
@@ -12,6 +12,7 @@ import { useContactsStore } from '../../stores/contacts-store';
import { useHasContacts } from '../../lib/capabilities';
import { isTrustedSendersSyncOn } from '../../lib/trusted-senders';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
interface TrustedRow {
email: string;
@@ -189,7 +190,7 @@ export function ContentSendersSettings() {
)}
</SettingsSection>
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={modalOpen}
transparent
animationType="fade"
+5 -3
View File
@@ -512,11 +512,13 @@ function makeStyles(c: ThemePalette) {
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: 'rgba(252, 211, 77, 0.4)',
backgroundColor: 'rgba(120, 53, 15, 0.25)',
// Theme warning tokens: the fixed dark-amber values left light-yellow
// text on a near-white fill in the light theme.
borderColor: c.warning,
backgroundColor: c.warningBg,
alignItems: 'flex-start',
},
opaqueText: { ...typography.caption, color: '#fde68a' },
opaqueText: { ...typography.caption, color: c.text },
opaqueActions: { flexDirection: 'row', gap: spacing.lg, marginTop: spacing.sm, flexWrap: 'wrap', alignItems: 'center' },
resetConfirmRow: { flexDirection: 'row', gap: spacing.md, alignItems: 'center' },
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
+6 -2
View File
@@ -25,6 +25,8 @@ import { useLocaleStore } from '../../stores/locale-store';
import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email';
import { jmapClient } from '../../api/jmap-client';
import type { Mailbox } from '../../api/types';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
const ROLE_ICON: Record<string, any> = {
inbox: Inbox, drafts: FileText, sent: Send, trash: Trash,
@@ -63,6 +65,8 @@ function pathOf(all: Mailbox[], mb: Mailbox, t: (k: string, f?: string) => strin
}
export function FolderSettings() {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -297,8 +301,8 @@ export function FolderSettings() {
)}
</SettingsSection>
<Modal visible={!!editor} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editor} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
+6 -2
View File
@@ -27,6 +27,8 @@ import { useLocaleStore } from '../../stores/locale-store';
import { isValidEmail, parseRecipientList, formatRecipient } from '../../lib/recipients';
import { sanitizeDisplayName } from '../../lib/rfc5322-mailbox';
import { sanitizeSignatureHtml } from '../../lib/signature-utils';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
type DraftIdentity = {
id: string;
@@ -92,6 +94,8 @@ function parseAddressList(value: string): { list: EmailAddress[]; invalid: strin
}
export function IdentitySettings() {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const identities = useSettingsStore((s) => s.identities);
@@ -298,8 +302,8 @@ export function IdentitySettings() {
</Pressable>
))}
<Modal visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
+12 -3
View File
@@ -45,8 +45,17 @@ export function KeywordSettings() {
}
};
const deleteKeyword = (id: string) => {
removeKeyword(id);
// Deleting a tag takes it off every message's tag list on this device; ask
// first, as the reset next to it does.
const deleteKeyword = (id: string, label: string) => {
Alert.alert(
t('settings.keywords.delete_confirm_title', 'Delete tag "{label}"?', { label }),
undefined,
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{ text: t('settings.keywords.delete', 'Delete tag'), style: 'destructive', onPress: () => removeKeyword(id) },
],
);
};
// One stray tap used to wipe a carefully built tag list (webmail removed
@@ -119,7 +128,7 @@ export function KeywordSettings() {
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit', "Edit tag")}>
<Pencil size={14} color={c.mutedForeground} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete', "Delete tag")}>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id, kw.label)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete', "Delete tag")}>
<Trash2 size={14} color={c.mutedForeground} />
</Pressable>
</View>
+8 -4
View File
@@ -17,8 +17,12 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useTemplatesStore, type EmailTemplate } from '../../stores/templates-store';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
export function TemplateSettings() {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const tr = useLocaleStore((s) => s.t);
@@ -196,8 +200,8 @@ export function TemplateSettings() {
</SettingsSection>
{/* Edit/Create modal */}
<Modal visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
@@ -270,8 +274,8 @@ export function TemplateSettings() {
</Modal>
{/* Import modal */}
<Modal visible={importVisible} animationType="slide" transparent onRequestClose={() => setImportVisible(false)}>
<View style={styles.modalOverlay}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={importVisible} animationType="slide" transparent onRequestClose={() => setImportVisible(false)}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{tr('settings.templates.import', 'Import')}</Text>
+7 -6
View File
@@ -242,18 +242,20 @@ export function VacationSettings() {
title={t('settings.vacation.message.title', 'Auto-Reply Message')}
description={t('settings.vacation.message.description', 'The message that will be sent as a reply')}
>
<SettingItem
label={t('settings.vacation.message.subject_label', 'Subject')}
description={t('settings.vacation.message.subject_description', 'Subject line of the auto-reply')}
>
{/* Stacked like the body below: beside its label, a fixed-width field
squeezed the label into five lines on a phone. */}
<View style={styles.bodyBlock}>
<Text style={styles.bodyLabel}>{t('settings.vacation.message.subject_label', 'Subject')}</Text>
<Text style={styles.bodyDesc}>{t('settings.vacation.message.subject_description', 'Subject line of the auto-reply')}</Text>
<TextInput
value={subject}
onChangeText={setSubject}
placeholder={t('settings.vacation.message.subject_placeholder', 'Out of Office')}
placeholderTextColor={c.mutedForeground}
accessibilityLabel={t('settings.vacation.message.subject_label', 'Subject')}
style={styles.subjectInput}
/>
</SettingItem>
</View>
<View style={styles.bodyBlock}>
<Text style={styles.bodyLabel}>{t('settings.vacation.message.body_label', 'Message Body')}</Text>
<Text style={styles.bodyDesc}>{t('settings.vacation.message.body_description', 'Plain text message content')}</Text>
@@ -431,7 +433,6 @@ function makeStyles(c: ThemePalette) {
},
inputInvalid: { borderColor: c.error },
subjectInput: {
width: 240,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
@@ -24,6 +24,8 @@ import {
import { otherAccountsSyncing, type SyncCollection } from '../../../device-sync/app/collections';
import { CONTACTS_AUTHORITY, type Authority } from '../../../device-sync/types';
import { useSyncCollections } from './use-sync-collections';
import { MODAL_ORIENTATIONS } from '../../../lib/modal-orientations';
import { useSheetInsets } from '../../../lib/use-layout';
interface Props {
visible: boolean;
@@ -48,6 +50,8 @@ export function DeviceSyncCollectionsSheet({ visible, onClose, registryId, autho
const changed = React.useRef(false);
const slideY = React.useRef(new Animated.Value(600)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const close = React.useCallback(() => {
const wasChanged = changed.current;
@@ -108,14 +112,14 @@ export function DeviceSyncCollectionsSheet({ visible, onClose, registryId, autho
: t('settings.device_sync.list_error_server', 'The server could not list them.');
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={close} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={close} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={close} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -133,7 +137,7 @@ export function DeviceSyncCollectionsSheet({ visible, onClose, registryId, autho
</View>
<Pressable
onPress={close}
hitSlop={8}
hitSlop={10}
style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
+2 -1
View File
@@ -14,6 +14,7 @@ import { useColors } from '../../theme/colors';
import ToggleSwitchComponent from '../ToggleSwitch';
import { useLocaleStore } from '../../stores/locale-store';
import { useSearchHighlight } from './search-highlight';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
/**
* Mirrors webmail settings-section.tsx:
@@ -193,7 +194,7 @@ export function Select({ value, onChange, options, style }: SelectProps) {
<Text style={styles.selectText}>{current?.label ?? ''}</Text>
<ChevronDown size={14} color={c.mutedForeground} />
</Pressable>
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={open}
transparent
animationType="fade"
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { formatCalendarDate } from '../date-format';
const oct15 = new Date(2026, 9, 15, 10, 0);
describe('formatCalendarDate', () => {
it('puts the day before the month in German', () => {
const s = formatCalendarDate(oct15, 'de', 'weekdayMonthDay');
expect(s).toMatch(/15\. Okt/);
expect(s).not.toMatch(/Okt\.? 15/);
});
it('keeps the US order in English', () => {
expect(formatCalendarDate(oct15, 'en-US', 'weekdayMonthDay')).toBe('Thu, Oct 15');
expect(formatCalendarDate(oct15, 'en', 'monthDayYear')).toBe('Oct 15, 2026');
});
it('names the weekday in full with the year for an all-day date', () => {
expect(formatCalendarDate(oct15, 'de', 'longWeekdayMonthDayYear')).toMatch(/^Donnerstag, 15\. Okt\.? 2026$/);
});
});
@@ -8,6 +8,7 @@ import {
import {
dayIndexIn,
monthFocusRow,
monthRowGeometry,
monthKeyOf,
monthMask,
sampledRow,
@@ -115,3 +116,21 @@ describe('dayIndexIn', () => {
expect(dayIndexIn([], new Date())).toBe(-1);
});
});
describe('monthRowGeometry', () => {
it('fits six weeks when they are at least the minimum height', () => {
expect(monthRowGeometry(600, 72)).toEqual({ rowHeight: 100, visibleRows: 6 });
});
it('shows fewer whole rows in a short window instead of running past it', () => {
// A phone in landscape: about 220 dp under the weekday header.
const { rowHeight, visibleRows } = monthRowGeometry(220, 72);
expect(visibleRows).toBe(3);
expect(rowHeight * visibleRows).toBeLessThanOrEqual(220);
expect(rowHeight).toBeGreaterThanOrEqual(72);
});
it('keeps one row when even that is taller than the window', () => {
expect(monthRowGeometry(50, 72)).toEqual({ rowHeight: 50, visibleRows: 1 });
});
});
@@ -8,8 +8,10 @@ import {
} from '../calendar-scroll-window';
import {
buildAllDaySegments,
columnsPerScreen,
headerColumnRange,
hourAtOffset,
multiDayCount,
timeGridFocusColumn,
windowDays,
} from '../calendar-time-grid';
@@ -118,3 +120,18 @@ describe('hourAtOffset', () => {
expect(hourAtOffset(-4, 48)).toBe(0);
});
});
describe('multiDayCount', () => {
it('shows three days on a phone, five on a medium window and seven on a wide one', () => {
expect(multiDayCount(411)).toBe(3);
expect(multiDayCount(800)).toBe(5);
expect(multiDayCount(1280)).toBe(7);
});
it('only changes the multi-day view', () => {
expect(columnsPerScreen('3day', 7)).toBe(7);
expect(columnsPerScreen('week', 5)).toBe(7);
expect(columnsPerScreen('day', 7)).toBe(1);
expect(columnsPerScreen('3day')).toBe(3);
});
});
+16
View File
@@ -13,6 +13,7 @@ import {
timePattern,
layoutOverlappingEvents,
CALENDAR_COLOR_PALETTE,
reopenedEdit,
} from '../calendar-utils';
import type { CalendarEvent } from '../../api/types';
import { useSettingsStore } from '../../stores/settings-store';
@@ -278,3 +279,18 @@ describe('layoutOverlappingEvents', () => {
expect(layoutOverlappingEvents(events, day)).toHaveLength(0);
});
});
describe('reopenedEdit', () => {
it('puts the unsaved edits over the event and drops cleared fields', () => {
const event = {
id: 'e1', title: 'Team sync', start: '2026-10-19T10:00:00', duration: 'PT30M',
locations: { l: { '@type': 'Location', name: 'Room 2' } },
} as unknown as CalendarEvent;
const reopened = reopenedEdit(event, { title: 'Team sync (moved)', start: '2026-10-19T11:00:00', locations: null });
expect(reopened.id).toBe('e1');
expect(reopened.title).toBe('Team sync (moved)');
expect(reopened.start).toBe('2026-10-19T11:00:00');
expect(reopened.duration).toBe('PT30M');
expect('locations' in reopened).toBe(false);
});
});
+10
View File
@@ -8,6 +8,7 @@ import {
hasRemoteContent,
prepareEmailHtml,
wrapPlainTextEmail,
wrapEmailHtml,
hasNativeDarkMode,
sniffImageMime,
TRANSPARENT_BLOCKED_PIXEL,
@@ -253,3 +254,12 @@ describe('sniffImageMime', () => {
expect(sniffImageMime(new Uint8Array([1, 2, 3]), 'application/octet-stream')).toBe('application/octet-stream');
});
});
describe('reading width on a wide pane', () => {
it('caps HTML and plain-text bodies only above the tablet breakpoint', () => {
const html = wrapEmailHtml('<p>Hello</p>');
expect(html).toMatch(/@media \(min-width: 840px\) \{\s*body \{ max-width: 720px;/);
const plain = wrapPlainTextEmail('Hello');
expect(plain).toContain('@media (min-width: 840px) { body { max-width: 80ch; } }');
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { filesGridLayout } from '../files-grid';
describe('filesGridLayout', () => {
it('keeps three columns on a phone', () => {
expect(filesGridLayout(411).columns).toBe(3);
});
it('adds columns on a wide window instead of growing the cells', () => {
const { columns, cellWidth } = filesGridLayout(1200);
expect(columns).toBeGreaterThan(3);
expect(cellWidth).toBeLessThan(160);
expect(cellWidth).toBeGreaterThanOrEqual(112);
});
it('fits the cells and gaps into the width', () => {
for (const width of [360, 411, 800, 1200, 2000]) {
const { columns, cellWidth } = filesGridLayout(width);
expect(cellWidth * columns + 8 * (columns - 1) + 16).toBeLessThanOrEqual(width);
}
});
});
+16
View File
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { listBackStep } from '../list-back';
describe('listBackStep', () => {
it('ends selection mode first', () => {
expect(listBackStep({ selecting: true, searching: true })).toBe('clear-selection');
});
it('clears an open search instead of leaving the app', () => {
expect(listBackStep({ selecting: false, searching: true })).toBe('clear-search');
});
it('leaves only from a plain list', () => {
expect(listBackStep({ selecting: false, searching: false })).toBe('leave');
});
});
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { monthChipShowsTime } from '../calendar-month-scroll';
describe('monthChipShowsTime', () => {
it('drops the time in a phone-portrait cell so the title shows', () => {
expect(monthChipShowsTime(true, 412 / 7)).toBe(false);
});
it('keeps the time where the cell has room', () => {
expect(monthChipShowsTime(true, 915 / 7)).toBe(true);
});
it('never shows the time when the setting is off', () => {
expect(monthChipShowsTime(false, 200)).toBe(false);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import type { Email } from '../../api/types';
import { replyComposeParams } from '../reply-compose';
const source = {
id: 'm1',
threadId: 't1',
mailboxIds: {},
keywords: {},
size: 1,
receivedAt: '2026-10-14T10:00:00Z',
subject: 'Hello',
from: [{ name: 'Petra', email: 'petra@example.net' }],
textBody: [{ partId: '1', type: 'text/plain' }],
bodyValues: { 1: { value: 'Please confirm.' } },
} as unknown as Email;
describe('replyComposeParams', () => {
it('carries the text typed in quick reply into the composer', () => {
const params = replyComposeParams('reply', source, undefined, 'Got it, thanks!');
expect(params?.prefillBody).toBe('Got it, thanks!');
expect(params?.replyTo?.from.email).toBe('petra@example.net');
});
it('adds nothing when nothing was typed', () => {
expect(replyComposeParams('reply', source)?.prefillBody).toBeUndefined();
expect(replyComposeParams('reply', source, undefined, ' ')?.prefillBody).toBeUndefined();
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect, vi } from 'vitest';
const error = vi.fn();
vi.mock('../../stores/toast-store', () => ({ toast: { error: (...a: unknown[]) => error(...a) } }));
vi.mock('../../stores/locale-store', () => ({
useLocaleStore: { getState: () => ({ t: (_k: string, fallback: string) => fallback }) },
}));
import { reportFailure } from '../report-failure';
describe('reportFailure', () => {
it('shows a toast when the action is refused', async () => {
reportFailure(Promise.reject(new Error('notUpdated: forbidden')));
await new Promise((r) => setTimeout(r, 0));
expect(error).toHaveBeenCalledWith('Failed to update email', 'notUpdated: forbidden');
});
it('stays quiet when the action works', async () => {
error.mockClear();
reportFailure(Promise.resolve());
await new Promise((r) => setTimeout(r, 0));
expect(error).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,23 @@
import { describe, it, expect, vi } from 'vitest';
const alert = vi.fn();
vi.mock('react-native', () => ({ Alert: { alert: (...a: unknown[]) => alert(...a) } }));
import { confirmSignOut } from '../sign-out-confirm';
const t = (_key: string, fallback: string, params?: Record<string, string | number>) =>
fallback.replace(/\{(\w+)\}/g, (_, k) => String(params?.[k] ?? ''));
describe('confirmSignOut', () => {
it('asks first and signs out only from the destructive button', () => {
const onConfirm = vi.fn();
confirmSignOut(t, { account: 'usera@example.org' }, onConfirm);
expect(onConfirm).not.toHaveBeenCalled();
const [title, , buttons] = alert.mock.calls[0] as [string, string, Array<{ style?: string; onPress?: () => void }>];
expect(title).toBe('Sign out of usera@example.org?');
buttons.find((b) => b.style === 'cancel')?.onPress?.();
expect(onConfirm).not.toHaveBeenCalled();
buttons.find((b) => b.style === 'destructive')!.onPress!();
expect(onConfirm).toHaveBeenCalledTimes(1);
});
});
+21
View File
@@ -12,6 +12,7 @@ import {
insertSignatureAboveQuote,
signatureIdentityFor,
signPlainTextReply,
signatureHasOwnDelimiter,
} from '../signature-utils';
describe('sanitizeSignatureHtml', () => {
@@ -115,3 +116,23 @@ describe('signPlainTextReply', () => {
expect(signPlainTextReply('Thanks', 'q', null, { position: 'above_quote', separator: true })).toBe('Thanks\n\nq');
});
});
describe('a signature with its own "-- " line', () => {
const own = { textSignature: '-- \nUser A\nstw-test' };
const plain = { textSignature: 'User A\nstw-test' };
it('is detected, also with a bare "--" or a leading blank line', () => {
expect(signatureHasOwnDelimiter(own)).toBe(true);
expect(signatureHasOwnDelimiter({ textSignature: '\n--\nUser A' })).toBe(true);
expect(signatureHasOwnDelimiter(plain)).toBe(false);
expect(signatureHasOwnDelimiter({ htmlSignature: '<p>-- </p><p>User A</p>' })).toBe(true);
});
it('gets no second separator in plain text or HTML', () => {
const delimiters = (text: string) => text.split('\n').filter((line) => /^--\s*$/.test(line)).length;
expect(delimiters(appendPlainTextSignature('Hi', own, { separator: true }))).toBe(1);
expect(appendPlainTextSignature('Hi', plain, { separator: true })).toBe('Hi\n\n-- \nUser A\nstw-test');
expect(buildEmbeddedSignatureHtml(own, { separator: true })).not.toContain('="separator"');
expect(buildEmbeddedSignatureHtml(plain, { separator: true })).toContain('="separator"');
});
});
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
type Listener = (e: unknown) => void;
const keyboardListeners = new Map<string, Listener>();
let dimsListener: ((e: { window: { width: number; height: number } }) => void) | null = null;
let keyboardVisible = false;
let screen = { width: 400, height: 900 };
vi.mock('react-native', () => ({
Dimensions: {
get: () => screen,
addEventListener: (_: string, fn: typeof dimsListener) => {
dimsListener = fn;
return { remove() {} };
},
},
Keyboard: {
addListener: (name: string, fn: Listener) => {
keyboardListeners.set(name, fn);
return { remove() {} };
},
isVisible: () => keyboardVisible,
dismiss: vi.fn(() => { keyboardVisible = false; }),
},
Platform: { OS: 'android' },
}));
// Just enough React to run the hook once: state goes to `obstruction`.
let obstruction = 0;
vi.mock('react', () => ({
default: {
useState: (initial: number) => [initial, (v: number) => { obstruction = v; }],
useRef: <T,>(v: T) => ({ current: v }),
useEffect: (fn: () => void) => { fn(); },
},
}));
import { Keyboard } from 'react-native';
import { keyboardObstruction, useKeyboardObstruction } from '../use-keyboard-obstruction';
beforeEach(() => {
keyboardListeners.clear();
dimsListener = null;
keyboardVisible = false;
screen = { width: 400, height: 900 };
obstruction = 0;
vi.clearAllMocks();
});
describe('keyboardObstruction', () => {
it('measures from the keyboard top to the screen bottom (gesture bar included)', () => {
// Keyboard 300 tall, reported without the 24 dp gesture bar.
expect(keyboardObstruction(900, 576, 300, 24)).toBe(324);
});
it('never covers more than the keyboard and the bottom inset', () => {
expect(keyboardObstruction(900, 100, 260, 24)).toBe(284);
});
it('is zero when the keyboard top is below the screen', () => {
expect(keyboardObstruction(411, 600, 260, 0)).toBe(0);
});
it('falls back to whichever value is known', () => {
expect(keyboardObstruction(900, undefined, 280, 24)).toBe(280);
expect(keyboardObstruction(900, 600, undefined, 24)).toBe(300);
expect(keyboardObstruction(900, undefined, undefined, 24)).toBe(0);
});
});
describe('useKeyboardObstruction', () => {
it('follows the keyboard events', () => {
useKeyboardObstruction(24);
keyboardVisible = true;
keyboardListeners.get('keyboardDidShow')!({ endCoordinates: { screenY: 576, height: 300 } });
expect(obstruction).toBe(324);
keyboardListeners.get('keyboardDidHide')!({});
expect(obstruction).toBe(0);
});
it('closes the keyboard and drops the stale overlap when the window rotates', () => {
const onWindowChange = vi.fn();
useKeyboardObstruction(24, onWindowChange);
keyboardVisible = true;
keyboardListeners.get('keyboardDidShow')!({ endCoordinates: { screenY: 576, height: 300 } });
expect(obstruction).toBe(324);
// Rotated: Android sends no keyboard event, only the new window size.
screen = { width: 900, height: 400 };
dimsListener!({ window: { width: 900, height: 400 } });
expect(obstruction).toBe(0);
expect(Keyboard.dismiss).toHaveBeenCalledTimes(1);
expect(onWindowChange).toHaveBeenCalledTimes(1);
});
it('leaves a closed keyboard alone on a window change', () => {
const onWindowChange = vi.fn();
useKeyboardObstruction(24, onWindowChange);
dimsListener!({ window: { width: 900, height: 400 } });
expect(Keyboard.dismiss).not.toHaveBeenCalled();
expect(onWindowChange).not.toHaveBeenCalled();
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('react-native', () => ({ useWindowDimensions: () => ({ width: 411, height: 914 }) }));
vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) }));
import { floatingCardFrame, layoutFor, listPaneWidth, sheetSideInsets } from '../use-layout';
describe('layoutFor', () => {
it('keeps a phone in portrait on the single-pane layout', () => {
expect(layoutFor(411, 914)).toEqual({
widthClass: 'compact', shortHeight: false, isSplit: false, hasSidebar: false,
});
});
it('keeps a phone in landscape single-pane although it is wide', () => {
const l = layoutFor(914, 411);
expect(l.widthClass).toBe('expanded');
expect(l.shortHeight).toBe(true);
expect(l.isSplit).toBe(false);
expect(l.hasSidebar).toBe(false);
});
it('splits a tablet in portrait without a sidebar', () => {
expect(layoutFor(800, 1280)).toEqual({
widthClass: 'medium', shortHeight: false, isSplit: true, hasSidebar: false,
});
});
it('splits a tablet in landscape and adds the sidebar', () => {
expect(layoutFor(1280, 800)).toEqual({
widthClass: 'expanded', shortHeight: false, isSplit: true, hasSidebar: true,
});
});
it('uses the window, so a narrow split-screen window is compact', () => {
expect(layoutFor(500, 800).isSplit).toBe(false);
});
it('puts the breakpoints at 600 and 840', () => {
expect(layoutFor(599, 900).widthClass).toBe('compact');
expect(layoutFor(600, 900).widthClass).toBe('medium');
expect(layoutFor(839, 900).widthClass).toBe('medium');
expect(layoutFor(840, 900).widthClass).toBe('expanded');
});
});
describe('listPaneWidth', () => {
it('stays between 320 and 420 dp', () => {
expect(listPaneWidth(600)).toBe(320);
expect(listPaneWidth(1000)).toBe(340);
expect(listPaneWidth(2000)).toBe(420);
});
});
describe('sheetSideInsets', () => {
it('keeps a phone sheet full width inside the side insets', () => {
expect(sheetSideInsets(411, 0, 0)).toEqual({ left: 0, right: 0 });
});
it('centres the sheet at 560 dp on a wide window', () => {
expect(sheetSideInsets(1280, 0, 0)).toEqual({ left: 360, right: 360 });
expect(sheetSideInsets(914, 0, 48)).toEqual({ left: 177, right: 177 });
});
it('never puts a centred sheet under a side inset', () => {
expect(sheetSideInsets(640, 0, 48)).toEqual({ left: 48, right: 48 });
});
});
describe('floatingCardFrame', () => {
it('spans a phone with 12 dp margins above the tab bar', () => {
expect(floatingCardFrame(411, false, { left: 0, right: 0 })).toEqual({ left: 12, right: 12, bottom: 64 });
});
it('sits bottom right at 360 dp on a split layout', () => {
const f = floatingCardFrame(1280, true, { left: 0, right: 0 });
expect(1280 - f.left - f.right).toBe(360);
expect(f.right).toBe(20);
expect(f.bottom).toBe(20);
});
});
+25
View File
@@ -34,6 +34,22 @@ export function monthFocusRow(
return Math.max(0, Math.min(rowCount - 1, row));
}
/**
* Row height and number of whole rows for a month grid of `height`. Six
* weeks fill the space when they fit at `minRow` or taller; a short window
* (a phone in landscape) shows fewer, taller-than-minimum rows instead of
* six rows that run off the bottom.
*/
export function monthRowGeometry(
height: number,
minRow: number,
maxRows = 6,
): { rowHeight: number; visibleRows: number } {
if (height <= 0 || minRow <= 0) return { rowHeight: minRow, visibleRows: maxRows };
const visibleRows = Math.max(1, Math.min(maxRows, Math.floor(height / minRow)));
return { rowHeight: Math.floor(height / visibleRows), visibleRows };
}
/** Row under the sample line for a scroll offset. */
export function sampledRow(
offset: number,
@@ -71,3 +87,12 @@ export function dayIndexIn(days: Date[], date: Date): number {
const diff = differenceInCalendarDays(date, days[0]);
return diff >= 0 && diff < days.length ? diff : -1;
}
// Below this cell width a start time fills the whole chip and the title never
// shows, as on a phone in portrait (about 58 dp, less at a larger font).
const MIN_CELL_WIDTH_FOR_TIME = 80;
/** Whether a full month cell of about `cellWidth` dp puts the start time before the title. */
export function monthChipShowsTime(showTimeSetting: boolean, cellWidth: number): boolean {
return showTimeSetting && cellWidth >= MIN_CELL_WIDTH_FOR_TIME;
}
+11 -2
View File
@@ -18,9 +18,18 @@ import type { DayRange, WeekStartsOn } from './calendar-scroll-window';
export type TimeGridMode = 'week' | '3day' | 'day';
/**
* Days the multi-day view ("3 days" on a phone) shows for a window `width`
* wide: three on a phone, five on a medium window, a full seven-day strip
* on a wide one, so the extra width buys days rather than wider columns.
*/
export function multiDayCount(width: number): number {
return width >= 840 ? 7 : width >= 600 ? 5 : 3;
}
/** Day columns on one screen. */
export function columnsPerScreen(mode: TimeGridMode): number {
return mode === 'week' ? 7 : mode === '3day' ? 3 : 1;
export function columnsPerScreen(mode: TimeGridMode, multiDays = 3): number {
return mode === 'week' ? 7 : mode === '3day' ? multiDays : 1;
}
/** Every day of the window, in order. */
+14
View File
@@ -604,3 +604,17 @@ export function getPrimaryCalendarId(
): string | undefined {
return Object.keys(event.calendarIds || {})[0];
}
/**
* The event as the editor last had it: the stored event with the unsaved
* edits on top, a field cleared in the editor (sent as `null`) removed. Used
* to reopen the editor when the recurrence-scope question is cancelled.
*/
export function reopenedEdit(event: CalendarEvent, updates: Partial<CalendarEvent>): CalendarEvent {
const merged: Record<string, unknown> = { ...event };
for (const [key, value] of Object.entries(updates)) {
if (value === null) delete merged[key];
else if (value !== undefined) merged[key] = value;
}
return merged as unknown as CalendarEvent;
}
+5 -3
View File
@@ -13,7 +13,7 @@ import { htmlToPlainText } from './compose-html';
import {
buildEmbeddedSignatureHtml, containsEmbeddedSignature, getPlainTextSignature,
plainTextBodyHasSignature, plainTextBodyWithoutSignature, spliceSignature,
type SignatureSource,
wantsSignatureSeparator, type SignatureSource,
} from './signature-utils';
/**
@@ -67,7 +67,9 @@ export function htmlComposeBodyToPlainText(
const head = text.slice(0, at).replace(/\s+$/, '');
const tail = text.slice(at + SIGNATURE_TOKEN.length).replace(/^\s+/, '');
const signature = getPlainTextSignature(identity);
const withSignature = signature ? `${head}${separatorText(options.separator)}${signature}` : head;
const withSignature = signature
? `${head}${separatorText(wantsSignatureSeparator(options.separator, identity))}${signature}`
: head;
return tail ? `${withSignature}\n\n${tail}` : withSignature;
}
@@ -86,7 +88,7 @@ export function plainComposeBodyToHtml(
const signature = getPlainTextSignature(identity);
const embedded = signature ? buildEmbeddedSignatureHtml(identity, options) : '';
if (embedded) {
const marker = `${separatorText(options.separator)}${signature}`;
const marker = `${separatorText(wantsSignatureSeparator(options.separator, identity))}${signature}`;
const at = text.indexOf(marker);
if (at !== -1) {
const before = plainTextToComposerHtml(text.slice(0, at).replace(/^\n+/, ''));
+18 -1
View File
@@ -17,7 +17,8 @@ import type { DateFormat, TimeFormat } from '../stores/settings-store';
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
type FormatName =
| 'time12' | 'time24' | 'full12' | 'full24' | 'weekday' | 'date' | 'monthDay' | 'monthDayYear';
| 'time12' | 'time24' | 'full12' | 'full24' | 'weekday' | 'date' | 'monthDay' | 'monthDayYear'
| 'weekdayMonthDay' | 'longWeekdayMonthDayYear';
const FORMATS: Record<FormatName, Intl.DateTimeFormatOptions> = {
time12: { hour: '2-digit', minute: '2-digit', hour12: true },
@@ -28,6 +29,8 @@ const FORMATS: Record<FormatName, Intl.DateTimeFormatOptions> = {
date: { year: 'numeric', month: '2-digit', day: '2-digit' },
monthDay: { month: 'short', day: 'numeric' },
monthDayYear: { month: 'short', day: 'numeric', year: 'numeric' },
weekdayMonthDay: { weekday: 'short', month: 'short', day: 'numeric' },
longWeekdayMonthDayYear: { weekday: 'long', month: 'short', day: 'numeric', year: 'numeric' },
};
// Building an Intl formatter is the expensive part of formatting a date (on
@@ -124,3 +127,17 @@ export function formatListDate(
return formatWith(d, intlLocale, 'date');
}
export type CalendarDateStyle = 'weekdayMonthDay' | 'longWeekdayMonthDayYear' | 'monthDay' | 'monthDayYear';
/**
* A calendar date in the order and words of `locale` ("Thu, Oct 15",
* "Do., 15. Okt."). The calendar used fixed US patterns such as
* 'EEE, MMM d', which put German and most other languages in the wrong order.
* `locale` is a language tag such as a date-fns locale's `code`.
*/
export function formatCalendarDate(date: Date, locale: string | undefined, style: CalendarDateStyle): string {
syncFormatterZone(new Date());
const tag = !locale || locale === 'en' ? 'en-US' : locale;
return formatWith(date, tag, style);
}
+8 -1
View File
@@ -449,6 +449,11 @@ body {
word-wrap: break-word;
overflow-wrap: break-word;
}
/* A wide reading pane (tablet): keep lines readable instead of running
150+ characters across. Phones never reach the breakpoint. */
@media (min-width: 840px) {
body { max-width: 720px; margin-left: auto; margin-right: auto; }
}
/* Only force the cap on images and tables that set no max-width of their own:
an !important 100% also overrides a sender's inline max-width, so an image
sized by max-width + max-height + width:100% grows to the pane width while
@@ -676,7 +681,7 @@ export function wrapPlainTextEmail(
const { isDark = true, font = 'sans' } = options;
const cleaned = stripDangerousTags(innerHtml);
const bg = isDark ? '#09090b' : '#ffffff';
const bg = isDark ? '#0a0a0a' : '#ffffff';
const fg = isDark ? '#fafafa' : '#1a1a1a';
const link = isDark ? '#60a5fa' : '#1a73e8';
const fontFamily = font === 'mono'
@@ -701,6 +706,8 @@ body {
}
a { color: ${link}; text-decoration: underline; }
details > summary::-webkit-details-marker { display: none; }
/* A wide reading pane (tablet): about 80 characters a line. */
@media (min-width: 840px) { body { max-width: 80ch; } }
`;
return `<!doctype html>
+15
View File
@@ -0,0 +1,15 @@
/**
* Columns and cell width of the Files grid for a list `width` wide: as many
* cells of at least `minCell` as fit, never fewer than three. Cells get a
* fixed width, so a short last row keeps the columns of the rows above
* instead of stretching its cells across the row.
*/
export function filesGridLayout(
width: number,
{ minCell = 112, padding = 8, gap = 8 }: { minCell?: number; padding?: number; gap?: number } = {},
): { columns: number; cellWidth: number } {
const inner = Math.max(0, width - padding * 2);
const columns = Math.max(3, Math.floor((inner + gap) / (minCell + gap)));
const cellWidth = Math.max(0, Math.floor((inner - gap * (columns - 1)) / columns));
return { columns, cellWidth };
}
+14
View File
@@ -0,0 +1,14 @@
/** What Android back does on a list screen (mail, contacts). */
export type ListBackStep = 'clear-selection' | 'clear-search' | 'leave';
/**
* Back works through the list's own layers first: selection mode ends, then
* an open search (and on mail its filters) clears. Only a plain list lets
* back `leave`: on the mail root that closes the app, on another tab it goes
* back to Mail. A search used to exit the app straight away.
*/
export function listBackStep(state: { selecting: boolean; searching: boolean }): ListBackStep {
if (state.selecting) return 'clear-selection';
if (state.searching) return 'clear-search';
return 'leave';
}
+14
View File
@@ -0,0 +1,14 @@
import type { ModalProps } from 'react-native';
/**
* Every RN `<Modal>` passes this. Without it iOS presents a modal in portrait
* only, which rotates an iPad (or a landscape iPhone) back to portrait while
* the modal is open.
*/
export const MODAL_ORIENTATIONS: NonNullable<ModalProps['supportedOrientations']> = [
'portrait',
'portrait-upside-down',
'landscape',
'landscape-left',
'landscape-right',
];
+5 -1
View File
@@ -5,12 +5,15 @@ import { pickEmailBody, plainTextBody } from './email-body';
/**
* Composer params for a reply / reply-all / forward of `source`. Shared by
* the thread viewer and the `mail/message/<id>?action=reply` deep link (the
* triage widget). Null when there is nobody to reply to.
* triage widget). Null when there is nobody to reply to. `typed` is text
* the user already wrote (quick reply's "More options"); it goes above the
* quote.
*/
export function replyComposeParams(
mode: 'reply' | 'replyAll' | 'forward',
source: Email,
ownerAccountId?: string,
typed?: string,
): NonNullable<RootStackParamList['Compose']> | null {
const from = source.from?.[0];
if (!from && mode !== 'forward') return null;
@@ -24,6 +27,7 @@ export function replyComposeParams(
const body = !quoteHtml || picked.text ? plainTextBody(source) : undefined;
return {
mode,
...(typed?.trim() ? { prefillBody: typed } : null),
replyTo: {
from: from ?? { email: '' },
to: source.to,
+17
View File
@@ -0,0 +1,17 @@
import { toast } from '../stores/toast-store';
import { useLocaleStore } from '../stores/locale-store';
/**
* Runs a store action the screen doesn't wait for (a swipe, a bulk button)
* and says when it fails. These used to be `void action()`: a change the
* server refused (not a dropped connection, which the outbox queues) went
* unnoticed.
*/
export function reportFailure(action: Promise<unknown>): void {
action.catch((err: unknown) => {
toast.error(
useLocaleStore.getState().t('notifications.error_updating', 'Failed to update email'),
err instanceof Error ? err.message : undefined,
);
});
}
+31
View File
@@ -0,0 +1,31 @@
import { Alert } from 'react-native';
type Translate = (key: string, fallback: string, params?: Record<string, string | number>) => string;
/**
* Asks before signing out: it drops the account's cached mail and push
* registration on this device, and a stray tap in the account menu or at the
* end of Settings did it straight away.
*/
export function confirmSignOut(
t: Translate,
{ account, all = false }: { account?: string; all?: boolean },
onConfirm: () => void,
): void {
const title = all
? t('sidebar.sign_out_all_confirm_title', 'Sign out of all accounts?')
: account
? t('sidebar.sign_out_confirm_title_account', 'Sign out of {account}?', { account })
: t('sidebar.sign_out_confirm_title', 'Sign out?');
const message = all
? t('sidebar.sign_out_all_confirm_message', 'Every account is removed from this device. You can sign in again at any time.')
: t('sidebar.sign_out_confirm_message', 'The account is removed from this device. You can sign in again at any time.');
Alert.alert(title, message, [
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{
text: all ? t('sidebar.sign_out_all', 'Sign out of all accounts') : t('sidebar.sign_out', 'Sign out'),
style: 'destructive',
onPress: onConfirm,
},
]);
}
+24 -3
View File
@@ -90,6 +90,27 @@ export function signatureIdentityFor<T extends { textSignature?: string; htmlSig
return primary && hasSignature(primary) ? primary : null;
}
/**
* Whether the signature starts with its own "-- " delimiter line. Many do
* (imported from other clients, or set on the server), and the composer's
* own separator on top of it showed two delimiter lines (webmail
* lib/signature-delimiter.ts).
*/
export function signatureHasOwnDelimiter(signature?: SignatureSource | null): boolean {
const text = signature?.textSignature?.trim()
? signature.textSignature
: signature?.htmlSignature?.trim()
? htmlToPlainText(sanitizeSignatureHtml(signature.htmlSignature))
: '';
const firstLine = text.replace(/\r\n?/g, '\n').replace(/^\s*\n/, '').split('\n')[0];
return /^--[ \t ]*$/.test(firstLine);
}
/** Whether to add the "-- " separator line in front of this signature. */
export function wantsSignatureSeparator(enabled: boolean | undefined, signature?: SignatureSource | null): boolean {
return enabled !== false && !signatureHasOwnDelimiter(signature);
}
export function getPlainTextSignature(signature?: SignatureSource): string {
if (signature?.textSignature?.trim()) {
return normalizeSignatureLineBreaks(signature.textSignature);
@@ -112,7 +133,7 @@ export function appendPlainTextSignature(
return body;
}
const sep = options.separator === false ? '\n\n' : '\n\n-- \n';
const sep = wantsSignatureSeparator(options.separator, signature) ? '\n\n-- \n' : '\n\n';
return `${body}${sep}${plainTextSignature}`;
}
@@ -186,7 +207,7 @@ export function appendHtmlSignature(
signature?: SignatureSource,
options: { separator?: boolean } = {},
): string {
const sep = options.separator === false ? '<br><br>' : '<br><br>-- <br>';
const sep = wantsSignatureSeparator(options.separator, signature) ? '<br><br>-- <br>' : '<br><br>';
if (signature?.htmlSignature?.trim()) {
return `${htmlBody}${sep}${sanitizeSignatureHtml(signature.htmlSignature)}`;
@@ -208,7 +229,7 @@ export function buildEmbeddedSignatureHtml(
identity: SignatureSource,
options: { separator: boolean },
): string {
const startMarker = options.separator
const startMarker = wantsSignatureSeparator(options.separator, identity)
? `<p ${SIGNATURE_RANGE_MARKER}="separator">-- </p>`
: `<p ${SIGNATURE_RANGE_MARKER}="start"></p>`;
const endMarker = `<p ${SIGNATURE_RANGE_MARKER}="end"><br></p>`;
+77
View File
@@ -0,0 +1,77 @@
import React from 'react';
import { Dimensions, Keyboard, Platform, type KeyboardEvent } from 'react-native';
/**
* How much of the screen bottom the keyboard covers, given the keyboard's top
* edge (`screenY`) and its reported height. On Android edge-to-edge the
* reported height leaves out the gesture bar, so the gap from the keyboard's
* top to the screen bottom is the better measure; it is capped by the
* keyboard height plus the bottom inset so a stale screen size (a rotation
* that arrives before the keyboard's own event) can't produce an overlap
* taller than the keyboard.
*/
export function keyboardObstruction(
screenHeight: number,
screenY: number | undefined,
height: number | undefined,
bottomInset: number,
): number {
const fromTop = typeof screenY === 'number' ? screenHeight - screenY : NaN;
const reported = typeof height === 'number' && height > 0 ? height : NaN;
if (Number.isNaN(fromTop) && Number.isNaN(reported)) return 0;
if (Number.isNaN(fromTop)) return Math.max(0, reported);
if (Number.isNaN(reported)) return Math.max(0, fromTop);
return Math.max(0, Math.min(fromTop, reported + bottomInset));
}
/**
* Tracks the keyboard's overlap with the bottom of the screen.
*
* Android sends no keyboard event when the window rotates with the keyboard
* open, so the last frame (from the other orientation) is all there is: a
* portrait keyboard kept as the overlap blanked the whole landscape composer.
* A window change with the keyboard open therefore closes the keyboard; the
* text stays, and the next tap opens it with the right size. `onWindowChange`
* lets a screen drop focus that `Keyboard.dismiss` can't reach (a WebView).
*/
export function useKeyboardObstruction(bottomInset: number, onWindowChange?: () => void): number {
const [obstruction, setObstruction] = React.useState(0);
const insetRef = React.useRef(bottomInset);
insetRef.current = bottomInset;
const onWindowChangeRef = React.useRef(onWindowChange);
onWindowChangeRef.current = onWindowChange;
React.useEffect(() => {
const apply = (e?: Pick<KeyboardEvent, 'endCoordinates'> | null) => {
if (!e?.endCoordinates) {
setObstruction(0);
return;
}
const { screenY, height } = e.endCoordinates;
setObstruction(keyboardObstruction(Dimensions.get('screen').height, screenY, height, insetRef.current));
};
const subs =
Platform.OS === 'ios'
? [
Keyboard.addListener('keyboardWillChangeFrame', apply),
Keyboard.addListener('keyboardWillHide', () => setObstruction(0)),
]
: [
Keyboard.addListener('keyboardDidShow', apply),
Keyboard.addListener('keyboardDidHide', () => setObstruction(0)),
];
let lastWindow = Dimensions.get('window');
const dims = Dimensions.addEventListener('change', ({ window }) => {
if (window.width === lastWindow.width && window.height === lastWindow.height) return;
lastWindow = window;
setObstruction(0);
if (!Keyboard.isVisible()) return;
Keyboard.dismiss();
onWindowChangeRef.current?.();
});
return () => {
for (const s of subs) s.remove();
dims.remove();
};
}, []);
return obstruction;
}
+113
View File
@@ -0,0 +1,113 @@
import React from 'react';
import { useWindowDimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
/**
* Window size classes for the adaptive layouts (docs/ux-audit-2026-10.md,
* "Adaptive layout plan"). They come from the window, not the device, so
* split-screen, freeform windows and foldables get the layout that fits.
*/
export type WidthClass = 'compact' | 'medium' | 'expanded';
export const MEDIUM_MIN_WIDTH = 600;
export const EXPANDED_MIN_WIDTH = 840;
/** Below this a window is "short": a phone in landscape. */
export const SHORT_MAX_HEIGHT = 480;
export interface Layout {
widthClass: WidthClass;
/** A phone in landscape: compress chrome, keep the single-pane layout. */
shortHeight: boolean;
/** List and detail side by side (medium or expanded, and not short). */
isSplit: boolean;
/** A persistent sidebar next to the list (expanded and not short). */
hasSidebar: boolean;
}
export function layoutFor(width: number, height: number): Layout {
const widthClass: WidthClass =
width >= EXPANDED_MIN_WIDTH ? 'expanded' : width >= MEDIUM_MIN_WIDTH ? 'medium' : 'compact';
const shortHeight = height < SHORT_MAX_HEIGHT;
const isSplit = widthClass !== 'compact' && !shortHeight;
return { widthClass, shortHeight, isSplit, hasSidebar: isSplit && widthClass === 'expanded' };
}
export function useLayout(): Layout {
const { width, height } = useWindowDimensions();
return layoutFor(width, height);
}
/**
* Width of the list pane next to a detail pane: a third of the window,
* between 320 and 420 dp (the webmail's list column is about 400).
*/
export function listPaneWidth(windowWidth: number): number {
return Math.round(Math.max(320, Math.min(420, windowWidth * 0.34)));
}
/** Width for sheets, dialogs and floating cards that would span a wide window. */
export const SHEET_MAX_WIDTH = 560;
/**
* Left and right offsets of a bottom sheet: centred at SHEET_MAX_WIDTH on a
* window 600 dp or wider (a phone in landscape included), else full width
* inside the side safe-area insets (a landscape notch or navigation bar).
*/
export function sheetSideInsets(
width: number,
insetLeft: number,
insetRight: number,
): { left: number; right: number } {
if (width >= MEDIUM_MIN_WIDTH) {
const side = Math.max(Math.round((width - SHEET_MAX_WIDTH) / 2), insetLeft, insetRight);
return { left: side, right: side };
}
return { left: insetLeft, right: insetRight };
}
/** `sheetSideInsets` for the current window. */
export function useSheetInsets(): { left: number; right: number } {
const { width } = useWindowDimensions();
const insets = useSafeAreaInsets();
const { left, right } = sheetSideInsets(width, insets.left, insets.right);
return React.useMemo(() => ({ left, right }), [left, right]);
}
/** APP.md toast card: 360 wide, 20 from the edges on a large screen. */
const FLOATING_CARD_WIDTH = 360;
const FLOATING_CARD_MARGIN = 20;
/** Phone: full width with 12 dp margins, above the 64 dp tab bar. */
const PHONE_CARD_MARGIN = 12;
const TAB_BAR_CLEARANCE = 64;
/**
* Where toasts, the undo bar and the push prompt float. A phone gets the full
* width above the tab bar; a split layout (tabs in a side rail) gets the
* webmail's desktop placement, bottom right.
*/
export function floatingCardFrame(
width: number,
split: boolean,
insets: { left: number; right: number },
): { left: number; right: number; bottom: number } {
if (split) {
return {
left: Math.max(FLOATING_CARD_MARGIN, width - FLOATING_CARD_WIDTH - FLOATING_CARD_MARGIN - insets.right),
right: FLOATING_CARD_MARGIN + insets.right,
bottom: FLOATING_CARD_MARGIN,
};
}
return {
left: PHONE_CARD_MARGIN + insets.left,
right: PHONE_CARD_MARGIN + insets.right,
bottom: TAB_BAR_CLEARANCE,
};
}
/** `floatingCardFrame` for the current window; `bottom` excludes the safe-area inset. */
export function useFloatingCardFrame(): { left: number; right: number; bottom: number } {
const { width, height } = useWindowDimensions();
const insets = useSafeAreaInsets();
const { left, right, bottom } = floatingCardFrame(width, layoutFor(width, height).isSplit, insets);
return React.useMemo(() => ({ left, right, bottom }), [left, right, bottom]);
}
+62
View File
@@ -0,0 +1,62 @@
import React from 'react';
import { NavigationProvider, type NavigationProp, type ParamListBase } from '@react-navigation/native';
/** True inside the detail pane of a split view. */
const PaneContext = React.createContext(false);
/** A screen in a split view's detail pane has no back button. */
export function useInPane(): boolean {
return React.useContext(PaneContext);
}
/** Routes a pane shows itself instead of pushing them on the stack. */
export type PaneTarget = { name: string; params?: object };
/**
* Runs a stack screen inside a split view's detail pane. The screen's own
* `useNavigation()` / `useRoute()` get the pane: `goBack` closes it, the
* routes `onOpen` accepts open in it, everything else goes to the stack.
*/
export function PaneScreen({
navigation,
name,
params,
onOpen,
onClose,
children,
}: {
navigation: NavigationProp<ParamListBase>;
name: string;
params: object;
/** Return true when the pane shows this route itself. */
onOpen: (target: PaneTarget) => boolean;
onClose: () => void;
children: React.ReactNode;
}) {
const route = React.useMemo(() => ({ key: `pane-${name}`, name, params }), [name, params]);
const paneNavigation = React.useMemo(() => {
const open = (method: 'navigate' | 'push' | 'replace') => (...args: unknown[]) => {
const [target, targetParams] = args as [string, object | undefined];
if (typeof target === 'string' && onOpen({ name: target, params: targetParams })) return;
// `push` and `replace` only exist on a stack; from a tab they become a
// navigate, which reaches the root stack all the same.
const real = navigation as unknown as Record<string, ((...a: unknown[]) => void) | undefined>;
(real[method] ?? real.navigate)!(...args);
};
return {
...navigation,
goBack: onClose,
navigate: open('navigate'),
push: open('push'),
replace: open('replace'),
canGoBack: () => true,
} as unknown as NavigationProp<ParamListBase>;
}, [navigation, onOpen, onClose]);
return (
<PaneContext.Provider value>
<NavigationProvider route={route} navigation={paneNavigation}>
{children}
</NavigationProvider>
</PaneContext.Provider>
);
}
+62 -18
View File
@@ -8,8 +8,10 @@ import {
StyleSheet,
Pressable,
ActivityIndicator,
useWindowDimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { multiDayCount } from '../lib/calendar-time-grid';
import {
Calendar as CalendarIcon,
ChevronDown,
@@ -84,6 +86,7 @@ import {
getTaskDueDate,
pickUnusedCalendarColor,
sharedCalendarColorKey,
reopenedEdit,
} from '../lib/calendar-utils';
import { buildReplyTo } from '../lib/calendar-invitation';
import {
@@ -110,10 +113,10 @@ function windowMode(mode: ViewMode): ScrollViewMode {
return mode === '3day' ? 'day' : mode;
}
// A window without free scrolling: one period, three days for the three-day grid.
function fixedWindowFor(mode: ViewMode, anchor: Date): ScrollWindowState {
// A window without free scrolling: one period, the multi-day grid's days.
function fixedWindowFor(mode: ViewMode, anchor: Date, multiDays: number): ScrollWindowState {
const state = fixedScrollWindowState(windowMode(mode), anchor);
return mode === '3day' ? { ...state, after: 2 } : state;
return mode === '3day' ? { ...state, after: multiDays - 1 } : state;
}
type PendingAction =
| {
@@ -189,6 +192,9 @@ export default function CalendarScreen() {
);
// The sideways-scrolling week and day grids assume a left-to-right strip;
// right-to-left layouts keep them paged.
// Wider windows show more days in the multi-day view.
const { width: windowWidth } = useWindowDimensions();
const multiDays = multiDayCount(windowWidth);
const freeScroll =
calendarFreeScroll
&& ((viewMode !== 'week' && viewMode !== '3day' && viewMode !== 'day') || !I18nManager.isRTL);
@@ -197,8 +203,8 @@ export default function CalendarScreen() {
() =>
freeScroll
? normalizeScrollWindowState(windowState, windowMode(viewMode), parseDayKey(focusKey))
: fixedWindowFor(viewMode, parseDayKey(focusKey)),
[freeScroll, windowState, viewMode, focusKey],
: fixedWindowFor(viewMode, parseDayKey(focusKey), multiDays),
[freeScroll, windowState, viewMode, focusKey, multiDays],
);
React.useEffect(() => {
if (freeScroll && activeWindowState !== windowState) setWindowState(activeWindowState);
@@ -482,21 +488,21 @@ export default function CalendarScreen() {
const base = visibleDate ?? focus.date;
jumpTo(
viewMode === 'week' ? subWeeks(base, 1)
: viewMode === '3day' ? subDays(base, 3)
: viewMode === '3day' ? subDays(base, multiDays)
: viewMode === 'day' ? subDays(base, 1)
: subMonths(base, 1),
);
}, [viewMode, visibleDate, focus.date, jumpTo]);
}, [viewMode, visibleDate, focus.date, jumpTo, multiDays]);
const goNext = React.useCallback(() => {
const base = visibleDate ?? focus.date;
jumpTo(
viewMode === 'week' ? addWeeks(base, 1)
: viewMode === '3day' ? addDays(base, 3)
: viewMode === '3day' ? addDays(base, multiDays)
: viewMode === 'day' ? addDays(base, 1)
: addMonths(base, 1),
);
}, [viewMode, visibleDate, focus.date, jumpTo]);
}, [viewMode, visibleDate, focus.date, jumpTo, multiDays]);
const goToday = React.useCallback(() => {
jumpTo(displayNow());
@@ -520,11 +526,17 @@ export default function CalendarScreen() {
() => [
{ key: 'agenda', label: t('calendar.views.schedule', 'Schedule'), Icon: Rows2 },
{ key: 'day', label: t('calendar.views.day', 'Day'), Icon: Square },
{ key: '3day', label: t('calendar.views.three_days', '3 days'), Icon: Columns3 },
{
key: '3day',
label: multiDays === 3
? t('calendar.views.three_days', '3 days')
: t('calendar.views.n_days', '{count} days', { count: multiDays }),
Icon: Columns3,
},
{ key: 'week', label: t('calendar.views.week', 'Week'), Icon: Columns4 },
{ key: 'month', label: t('calendar.views.month', 'Month'), Icon: Grid3x3 },
],
[t],
[t, multiDays],
);
// The month that drops down under the title; the title follows the month
@@ -623,15 +635,32 @@ export default function CalendarScreen() {
[t],
);
// A single event is gone for good once deleted; ask first, as the
// recurrence-scope dialog already does for a series.
const confirmDeleteEvent = React.useCallback((event: CalendarEvent) => {
Alert.alert(
t('calendar.detail.delete_confirm', 'Delete this event?'),
event.title || undefined,
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{
text: t('calendar.recurrence_scope.delete', 'Delete'),
style: 'destructive',
onPress: () => { deleteEvent(event.id).catch(reportError); },
},
],
);
}, [deleteEvent, reportError, t]);
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
setDetailEvent(null);
if (isRecurringSeriesMember(event)) {
setPendingAction({ kind: 'delete', event });
} else {
deleteEvent(event.id).catch(reportError);
confirmDeleteEvent(event);
}
}, [deleteEvent, isReadOnlyEvent, reportError]);
}, [confirmDeleteEvent, isReadOnlyEvent]);
// "This and following": end the master at the occurrence and hand back the
// master plus its untouched rules so the caller can start a new series (or
@@ -797,6 +826,16 @@ export default function CalendarScreen() {
[modalEvent, createEvent, updateEvent],
);
// Cancelling "which occurrences?" after editing a recurring event goes back
// to the editor with the edits still in it; they used to be thrown away.
const cancelScope = React.useCallback(() => {
const pending = pendingAction;
setPendingAction(null);
if (pending?.kind !== 'edit') return;
setModalEvent(reopenedEdit(pending.event, pending.updates));
setModalVisible(true);
}, [pendingAction]);
const handleDeleteFromModal = React.useCallback(
async (event: CalendarEvent) => {
setModalVisible(false);
@@ -804,9 +843,9 @@ export default function CalendarScreen() {
setPendingAction({ kind: 'delete', event });
return;
}
await deleteEvent(event.id).catch(reportError);
confirmDeleteEvent(event);
},
[deleteEvent, reportError],
[confirmDeleteEvent],
);
const onRefresh = React.useCallback(() => refresh(), [refresh]);
@@ -827,12 +866,16 @@ export default function CalendarScreen() {
const handleDeleteTask = React.useCallback(
(id: string) => {
deleteTask(id).catch((err: unknown) => {
const run = () => deleteTask(id).catch((err: unknown) => {
Alert.alert(
t('calendar.tasks.delete_error', 'Failed to delete task'),
err instanceof Error ? err.message : undefined,
);
});
Alert.alert(t('calendar.tasks.delete_confirm', 'Delete this task?'), undefined, [
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{ text: t('calendar.tasks.delete', 'Delete'), style: 'destructive', onPress: () => { void run(); } },
]);
},
[deleteTask, t],
);
@@ -956,7 +999,7 @@ export default function CalendarScreen() {
const titleDate = (monthPanelOpen && panelMonth) || visibleDate || focus.date;
return (
<SafeAreaView style={styles.container} edges={['top']}>
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
<View style={styles.header}>
<Pressable
onPress={() => setSidebarVisible(true)}
@@ -1094,6 +1137,7 @@ export default function CalendarScreen() {
<TimeGridScrollView
key={windowKey}
mode={viewMode}
multiDays={multiDays}
focus={focus}
window={scrollWindow}
onExtendStart={freeScroll && scrollWindow.canExtendStart ? extendWindowStart : undefined}
@@ -1199,7 +1243,7 @@ export default function CalendarScreen() {
visible={!!pendingAction}
actionType={pendingAction?.kind ?? 'edit'}
onSelect={handleScopeSelect}
onClose={() => setPendingAction(null)}
onClose={cancelScope}
/>
<CalendarSidebarDrawer
+204 -124
View File
@@ -1,7 +1,7 @@
import React from 'react';
import {
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
Keyboard, Dimensions, Platform, ActivityIndicator, Alert, Modal, Switch,
Keyboard, Platform, ActivityIndicator, Alert, Modal, Switch, useWindowDimensions,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { usePreventRemove } from '@react-navigation/native';
@@ -31,6 +31,8 @@ import { useEmailStore } from '../stores/email-store';
import { ownMailboxes } from '../lib/mailbox-tree';
import { getEmailInitials } from '../lib/avatar-utils';
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
import { useLayout } from '../lib/use-layout';
import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
import { useHasContacts } from '../lib/capabilities';
@@ -65,7 +67,7 @@ import { shouldBlockEditorRemoteImages } from '../lib/editor-html';
import {
signatureIdentityFor, buildEmbeddedSignatureHtml, containsEmbeddedSignature, spliceSignature,
insertSignatureAboveQuote, getPlainTextSignature, appendPlainTextSignature,
plainTextBodyHasSignature, plainTextBodyWithoutSignature, SIGNATURE_RANGE_MARKER,
plainTextBodyHasSignature, plainTextBodyWithoutSignature, SIGNATURE_RANGE_MARKER, wantsSignatureSeparator,
} from '../lib/signature-utils';
import {
getAutoFilledPlaceholders, getPlaceholdersFromTemplate, substitutePlaceholders, templateBodyToHtml,
@@ -76,6 +78,7 @@ import {
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
import type { EmailAddress, Identity } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
type Props = NativeStackScreenProps<RootStackParamList, 'Compose'>;
@@ -210,7 +213,8 @@ function RecipientChip({
</Text>
<Pressable
onPress={onRemove}
hitSlop={8}
// A 12 px icon: the touch area reaches about 40 dp around it.
hitSlop={14}
accessibilityRole="button"
accessibilityLabel={t('email_composer.recipient_remove', 'Remove')}
>
@@ -338,7 +342,9 @@ function ToolbarButton({
onPress={onPress}
hitSlop={4}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ selected: !!active, disabled: !!disabled }}
style={[styles.formatBtn, active && styles.formatBtnActive, disabled && styles.formatBtnDisabled]}
>
{icon}
@@ -365,7 +371,7 @@ function OptionsSheet({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Pressable style={styles.modalBackdrop} onPress={onClose}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
{!!title && <Text style={styles.modalTitle} numberOfLines={2}>{title}</Text>}
@@ -395,37 +401,16 @@ export default function ComposeScreen({ route, navigation }: Props) {
const timeFormat = useSettingsStore((s) => s.timeFormat);
const insets = useSafeAreaInsets();
// Track the visible keyboard obstruction so the format bar stays above it.
// On Android edge-to-edge, the IME-inset reported by `keyboardDidShow` is
// measured from the top of the gesture-nav bar rather than from the true
// screen bottom, so we derive obstruction height from `Dimensions.screen`
// instead of trusting `endCoordinates.height` directly. The same trick
// works on iOS for the QuickType / autofill / dictation strip.
const [kbObstruction, setKbObstruction] = React.useState(0);
React.useEffect(() => {
const recompute = (endY: number) => {
const screenH = Dimensions.get('screen').height;
setKbObstruction(Math.max(0, screenH - endY));
};
const subs =
Platform.OS === 'ios'
? [
Keyboard.addListener('keyboardWillChangeFrame', (e) => {
recompute(e.endCoordinates?.screenY ?? Number.MAX_SAFE_INTEGER);
}),
]
: [
Keyboard.addListener('keyboardDidShow', (e) => {
recompute(e.endCoordinates?.screenY ?? Number.MAX_SAFE_INTEGER);
}),
Keyboard.addListener('keyboardDidHide', () => setKbObstruction(0)),
];
return () => {
for (const s of subs) s.remove();
};
}, []);
const kbObstruction = useKeyboardObstruction(insets.bottom, () => editorRef.current?.blur());
const { shortHeight, isSplit } = useLayout();
const typingInShortWindow = shortHeight && kbObstruction > 0;
// When the keyboard is up it covers the bottom safe area, so we don't
// need to add it on top — pad by whichever is larger.
const bottomPad = Math.max(kbObstruction, insets.bottom);
// On a tablet the composer is a centred panel that stops PANEL_MARGIN
// short of the screen bottom; the keyboard covers that much less of it.
const bottomPad = isSplit
? Math.max(0, kbObstruction - PANEL_MARGIN)
: Math.max(kbObstruction, insets.bottom);
const replyTo = route.params?.replyTo;
const draft = route.params?.draft;
const mode = route.params?.mode ?? 'compose';
@@ -680,6 +665,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
});
const editorRef = React.useRef<RichTextEditorHandle>(null);
// The label column grows with the system font size: at 1.3 "Betreff"
// wrapped into "Betref" / "f" in a fixed 56 dp column.
const { fontScale } = useWindowDimensions();
const fieldLabelWidth = React.useMemo(() => ({ width: Math.round(56 * Math.max(1, fontScale)) }), [fontScale]);
const subjectRef = React.useRef<TextInput>(null);
const plainBodyRef = React.useRef<TextInput>(null);
const [subjectFocused, setSubjectFocused] = React.useState(false);
const isPickingSuggestion = React.useRef(false);
// Track inline-image placeholders that haven't yet been rewritten to cid:
// until send time. Maps cid → blobId/type/name/size.
@@ -1080,6 +1072,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const [draftStatus, setDraftStatus] = React.useState<'idle' | 'saving' | 'saved' | 'failed'>('idle');
const [savingDraft, setSavingDraft] = React.useState(false);
const [, bumpBaseline] = React.useReducer((n: number) => n + 1, 0);
const isDirty = currentSnapshot !== baselineRef.current;
const needsSave = currentSnapshot !== (lastSavedRef.current ?? baselineRef.current);
@@ -1101,7 +1094,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const placeSignaturePlain = React.useCallback((body: string, identity: Identity): string => {
const sig = getPlainTextSignature(identity);
if (!sig) return body;
const sep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
const sep = wantsSignatureSeparator(signatureSeparatorEnabled, identity) ? '\n\n-- \n' : '\n\n';
if (isReplyLike && signaturePosition === 'above_quote') {
const idx = body.indexOf('\n\n');
const head = idx === -1 ? body : body.slice(0, idx);
@@ -1138,10 +1131,21 @@ export default function ComposeScreen({ route, navigation }: Props) {
const next = placeSignatureHtml(live, sigHtml);
setBodyHtml(next);
editorRef.current?.setHtml(next);
// The untouched baseline is the editor's own read-back: the page
// normalises the markup, so the string built here never equalled
// what it reports, and an untouched reply counted as edited (it
// autosaved a draft and asked "Save or discard?" on close).
let settled = placeSignatureHtml(initialBodyHtml, sigHtml);
try {
settled = (await editorRef.current?.getHtml()) ?? settled;
} catch { /* keep the built string */ }
if (latestRef.current.bodyHtml === next) setBodyHtml(settled);
baselineRef.current = snapshotOf({
to: initialTo, cc: initialCc, bcc: initialBcc, subject: initialSubject,
body: placeSignatureHtml(initialBodyHtml, sigHtml), attachments: initialAttachments,
body: settled, attachments: initialAttachments,
});
// The baseline is a ref: re-render so "edited" is worked out again.
bumpBaseline();
})();
}
return;
@@ -1157,7 +1161,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
stripped = plainTextBodyWithoutSignature(body, prev);
} else if (prev) {
const old = getPlainTextSignature(prev);
const marker = `${signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n'}${old}`;
const marker = `${wantsSignatureSeparator(signatureSeparatorEnabled, prev) ? '\n\n-- \n' : '\n\n'}${old}`;
if (old && body.includes(marker)) stripped = body.replace(marker, '');
}
return signatureIdentity ? placeSignaturePlain(stripped, signatureIdentity) : stripped;
@@ -2214,7 +2218,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const invalidInput = typedRecipients[field].invalid.length > 0 && !typedRecipients[field].valid.length && input.trim().length > 0 && activeField !== field;
return (
<View style={[styles.fieldRow, { zIndex }]}>
<Text style={styles.fieldLabel}>{label}</Text>
<Text style={[styles.fieldLabel, fieldLabelWidth]} numberOfLines={1}>{label}</Text>
<View style={styles.recipientField}>
{list.map((r, i) => (
<RecipientChip
@@ -2226,6 +2230,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
/>
))}
<TextInput
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
style={[styles.recipientInput, invalidInput && styles.recipientInputInvalid]}
placeholder={placeholder}
placeholderTextColor={c.textMuted}
@@ -2252,7 +2259,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
isPickingSuggestion.current = false;
}, 200);
}}
onSubmitEditing={() => addTyped(field)}
// Return turns typed text into a chip; on an empty field it
// moves on to the subject.
returnKeyType="next"
onSubmitEditing={() => {
if (inputFor(field).trim()) addTyped(field);
else subjectRef.current?.focus();
}}
blurOnSubmit={false}
keyboardType="email-address"
autoCapitalize="none"
@@ -2269,12 +2282,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
/>
)}
{field === 'to' && !ccVisible && (
<Pressable onPress={() => setCcVisible(true)} style={styles.ccToggle}>
<Pressable onPress={() => setCcVisible(true)} style={styles.ccToggle} hitSlop={10} accessibilityRole="button">
<Text style={styles.ccToggleText}>{t('email_composer.cc', 'Cc')}</Text>
</Pressable>
)}
{field === 'cc' && !bccVisible && (
<Pressable onPress={() => setBccVisible(true)} style={styles.ccToggle}>
<Pressable onPress={() => setBccVisible(true)} style={styles.ccToggle} hitSlop={10} accessibilityRole="button">
<Text style={styles.ccToggleText}>{t('email_composer.bcc', 'Bcc')}</Text>
</Pressable>
)}
@@ -2282,9 +2295,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
const composer = (
<SafeAreaView style={styles.container} edges={isSplit ? [] : ['top', 'left', 'right']}>
<View style={[styles.header, typingInShortWindow && styles.headerCompact]}>
<Pressable
onPress={onClose}
style={styles.headerBtn}
@@ -2298,7 +2311,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<Text style={styles.headerTitle} numberOfLines={1}>
{t(titleKey, mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message')}
</Text>
{!!draftStatusText && (
{!!draftStatusText && !typingInShortWindow && (
<Text style={styles.headerSubtitle} numberOfLines={1}>{draftStatusText}</Text>
)}
</View>
@@ -2354,7 +2367,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
contentContainerStyle={styles.scrollContent}
>
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>{t('email_composer.from', 'From')}</Text>
<Text style={[styles.fieldLabel, fieldLabelWidth]} numberOfLines={1}>{t('email_composer.from', 'From')}</Text>
<Pressable
onPress={openIdentityPicker}
onLongPress={openFromOptions}
@@ -2384,13 +2397,26 @@ export default function ComposeScreen({ route, navigation }: Props) {
{bccVisible && renderRecipientField('bcc', t('email_composer.bcc', 'Bcc'), t('email_composer.bcc_placeholder', 'Bcc recipients'), 4)}
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>{t('email_composer.subject', 'Subject')}</Text>
<Text style={[styles.fieldLabel, fieldLabelWidth]} numberOfLines={1}>{t('email_composer.subject', 'Subject')}</Text>
<TextInput
ref={subjectRef}
disableFullscreenUI
style={styles.subjectInput}
placeholder={t('email_composer.subject_placeholder', 'Subject')}
placeholderTextColor={c.textMuted}
value={subject}
onChangeText={setSubject}
// Unfocused, a long subject shows from its start ("Re: …"), not
// scrolled to its end.
selection={subjectFocused ? undefined : SELECTION_START}
onFocus={() => setSubjectFocused(true)}
onBlur={() => setSubjectFocused(false)}
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => {
if (plainTextMode) plainBodyRef.current?.focus();
else editorRef.current?.focus();
}}
/>
</View>
@@ -2409,6 +2435,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
{plainTextMode ? (
<TextInput
ref={plainBodyRef}
disableFullscreenUI
style={styles.plainEditor}
multiline
value={plainBody}
@@ -2431,92 +2459,116 @@ export default function ComposeScreen({ route, navigation }: Props) {
)}
</ScrollView>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.formatBar}
contentContainerStyle={styles.formatActions}
keyboardShouldPersistTaps="always"
>
<ToolbarButton onPress={() => setTemplateSheetOpen(true)}
icon={<LayoutTemplate size={18} color={c.textSecondary} />} />
<ToolbarButton active={requestReadReceipt} onPress={() => setRequestReadReceipt((v) => !v)}
icon={<MailCheck size={18} color={requestReadReceipt ? c.primary : c.textSecondary} />} />
<ToolbarButton active={plainTextMode} onPress={() => { void togglePlainTextMode(); }}
label={plainTextMode
? t('email_composer.format_rich_text', 'Switch to rich text (HTML)')
: t('email_composer.format_plain_text', 'Switch to plain text')}
icon={<Type size={18} color={plainTextMode ? c.primary : c.textSecondary} />} />
{/* A phone in landscape with the keyboard up has room for a few lines
of text only; the format bar comes back with the keyboard down. */}
{!typingInShortWindow && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.formatBar}
contentContainerStyle={styles.formatActions}
keyboardShouldPersistTaps="always"
>
<ToolbarButton onPress={() => setTemplateSheetOpen(true)}
label={t('email_composer.use_template', 'Template')}
icon={<LayoutTemplate size={18} color={c.textSecondary} />} />
<ToolbarButton active={requestReadReceipt} onPress={() => setRequestReadReceipt((v) => !v)}
label={t('email_composer.read_receipt_off', 'Request a read receipt')}
icon={<MailCheck size={18} color={requestReadReceipt ? c.primary : c.textSecondary} />} />
<ToolbarButton active={plainTextMode} onPress={() => { void togglePlainTextMode(); }}
label={plainTextMode
? t('email_composer.format_rich_text', 'Switch to rich text (HTML)')
: t('email_composer.format_plain_text', 'Switch to plain text')}
icon={<Type size={18} color={plainTextMode ? c.primary : c.textSecondary} />} />
{!plainTextMode && (
<>
<View style={styles.formatSep} />
{!plainTextMode && (
<>
<View style={styles.formatSep} />
<ToolbarButton active={selState.bold} onPress={() => editorRef.current?.exec('bold')}
icon={<Bold size={18} color={selState.bold ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.italic} onPress={() => editorRef.current?.exec('italic')}
icon={<Italic size={18} color={selState.italic ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.underline} onPress={() => editorRef.current?.exec('underline')}
icon={<Underline size={18} color={selState.underline ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.strikeThrough} onPress={() => editorRef.current?.exec('strikeThrough')}
icon={<Strikethrough size={18} color={selState.strikeThrough ? c.primary : c.textSecondary} />} />
<ToolbarButton onPress={() => setColorTarget('text')}
label={t('email_composer.toolbar.text_color', 'Text color')}
icon={<Palette size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => setColorTarget('background')}
label={t('email_composer.toolbar.background_color', 'Background color')}
icon={<Highlighter size={18} color={c.textSecondary} />} />
<ToolbarButton active={selState.bold} onPress={() => editorRef.current?.exec('bold')}
label={t('email_composer.toolbar.bold', 'Bold')}
icon={<Bold size={18} color={selState.bold ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.italic} onPress={() => editorRef.current?.exec('italic')}
label={t('email_composer.toolbar.italic', 'Italic')}
icon={<Italic size={18} color={selState.italic ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.underline} onPress={() => editorRef.current?.exec('underline')}
label={t('email_composer.toolbar.underline', 'Underline')}
icon={<Underline size={18} color={selState.underline ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.strikeThrough} onPress={() => editorRef.current?.exec('strikeThrough')}
label={t('email_composer.toolbar.strikethrough', 'Strikethrough')}
icon={<Strikethrough size={18} color={selState.strikeThrough ? c.primary : c.textSecondary} />} />
<ToolbarButton onPress={() => setColorTarget('text')}
label={t('email_composer.toolbar.text_color', 'Text color')}
icon={<Palette size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => setColorTarget('background')}
label={t('email_composer.toolbar.background_color', 'Background color')}
icon={<Highlighter size={18} color={c.textSecondary} />} />
<View style={styles.formatSep} />
<View style={styles.formatSep} />
<ToolbarButton active={selState.h1} onPress={() => editorRef.current?.exec('formatBlock:H1')}
icon={<Heading1 size={18} color={selState.h1 ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.h2} onPress={() => editorRef.current?.exec('formatBlock:H2')}
icon={<Heading2 size={18} color={selState.h2 ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.h1} onPress={() => editorRef.current?.exec('formatBlock:H1')}
label={t('email_composer.toolbar.heading_1', 'Heading 1')}
icon={<Heading1 size={18} color={selState.h1 ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.h2} onPress={() => editorRef.current?.exec('formatBlock:H2')}
label={t('email_composer.toolbar.heading_2', 'Heading 2')}
icon={<Heading2 size={18} color={selState.h2 ? c.primary : c.textSecondary} />} />
<View style={styles.formatSep} />
<View style={styles.formatSep} />
<ToolbarButton active={selState.ul} onPress={() => editorRef.current?.exec('insertUnorderedList')}
icon={<List size={18} color={selState.ul ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.ol} onPress={() => editorRef.current?.exec('insertOrderedList')}
icon={<ListOrdered size={18} color={selState.ol ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.blockquote} onPress={() => editorRef.current?.exec('formatBlock:BLOCKQUOTE')}
icon={<Quote size={18} color={selState.blockquote ? c.primary : c.textSecondary} />} />
<ToolbarButton onPress={() => setTableMenuOpen(true)}
icon={<Table size={18} color={c.textSecondary} />} />
<ToolbarButton active={selState.ul} onPress={() => editorRef.current?.exec('insertUnorderedList')}
label={t('email_composer.toolbar.bullet_list', 'Bullet list')}
icon={<List size={18} color={selState.ul ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.ol} onPress={() => editorRef.current?.exec('insertOrderedList')}
label={t('email_composer.toolbar.ordered_list', 'Ordered list')}
icon={<ListOrdered size={18} color={selState.ol ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.blockquote} onPress={() => editorRef.current?.exec('formatBlock:BLOCKQUOTE')}
label={t('email_composer.toolbar.quote', 'Quote')}
icon={<Quote size={18} color={selState.blockquote ? c.primary : c.textSecondary} />} />
<ToolbarButton onPress={() => setTableMenuOpen(true)}
label={t('email_composer.toolbar.table', 'Table')}
icon={<Table size={18} color={c.textSecondary} />} />
<View style={styles.formatSep} />
<View style={styles.formatSep} />
<ToolbarButton active={selState.alignLeft} onPress={() => editorRef.current?.exec('justifyLeft')}
icon={<AlignLeft size={18} color={selState.alignLeft ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.alignCenter} onPress={() => editorRef.current?.exec('justifyCenter')}
icon={<AlignCenter size={18} color={selState.alignCenter ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.alignRight} onPress={() => editorRef.current?.exec('justifyRight')}
icon={<AlignRight size={18} color={selState.alignRight ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.alignLeft} onPress={() => editorRef.current?.exec('justifyLeft')}
label={t('email_composer.toolbar.align_left', 'Align left')}
icon={<AlignLeft size={18} color={selState.alignLeft ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.alignCenter} onPress={() => editorRef.current?.exec('justifyCenter')}
label={t('email_composer.toolbar.align_center', 'Align center')}
icon={<AlignCenter size={18} color={selState.alignCenter ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.alignRight} onPress={() => editorRef.current?.exec('justifyRight')}
label={t('email_composer.toolbar.align_right', 'Align right')}
icon={<AlignRight size={18} color={selState.alignRight ? c.primary : c.textSecondary} />} />
<View style={styles.formatSep} />
<View style={styles.formatSep} />
<ToolbarButton active={selState.link} onPress={onLinkPress}
icon={selState.link
? <Link2Off size={18} color={c.primary} />
: <Link2 size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => { void insertInlineImage(); }}
icon={<ImageIcon size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => editorRef.current?.exec('removeFormat')}
icon={<RemoveFormatting size={18} color={c.textSecondary} />} />
<ToolbarButton active={selState.link} onPress={onLinkPress}
label={selState.link ? t('email_composer.toolbar.remove_link', 'Remove link') : t('email_composer.toolbar.link', 'Link')}
icon={selState.link
? <Link2Off size={18} color={c.primary} />
: <Link2 size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => { void insertInlineImage(); }}
label={t('email_composer.toolbar.insert_image', 'Insert image')}
icon={<ImageIcon size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => editorRef.current?.exec('removeFormat')}
label={t('email_composer.toolbar.clear_formatting', 'Clear formatting')}
icon={<RemoveFormatting size={18} color={c.textSecondary} />} />
<View style={styles.formatSep} />
<View style={styles.formatSep} />
<ToolbarButton onPress={() => editorRef.current?.exec('undo')}
icon={<Undo2 size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => editorRef.current?.exec('redo')}
icon={<Redo2 size={18} color={c.textSecondary} />} />
</>
)}
</ScrollView>
<ToolbarButton onPress={() => editorRef.current?.exec('undo')}
label={t('email_composer.toolbar.undo', 'Undo')}
icon={<Undo2 size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => editorRef.current?.exec('redo')}
label={t('email_composer.toolbar.redo', 'Redo')}
icon={<Redo2 size={18} color={c.textSecondary} />} />
</>
)}
</ScrollView>
)}
</View>
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={linkPromptVisible}
transparent
animationType="fade"
@@ -2567,7 +2619,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</Modal>
{/* Text / background colour palette */}
<Modal visible={!!colorTarget} transparent animationType="fade" onRequestClose={() => setColorTarget(null)}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!colorTarget} transparent animationType="fade" onRequestClose={() => setColorTarget(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setColorTarget(null)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<Text style={styles.modalTitle}>
@@ -2631,7 +2683,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
/>
{/* Schedule send sheet */}
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={scheduleSheetOpen}
transparent
animationType="fade"
@@ -2670,7 +2722,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</Modal>
{customStage !== null && Platform.OS === 'ios' && (
<Modal transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<DateTimePicker
@@ -2711,7 +2763,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
)}
{/* Template placeholders */}
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={!!placeholderPrompt}
transparent
animationType="fade"
@@ -2766,7 +2818,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</Modal>
{/* From options: sub-address tag / From override */}
<Modal visible={fromOptionsOpen} transparent animationType="fade" onRequestClose={() => setFromOptionsOpen(false)}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={fromOptionsOpen} transparent animationType="fade" onRequestClose={() => setFromOptionsOpen(false)}>
<View style={styles.modalBackdrop}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>{t('email_composer.from', 'From')}</Text>
@@ -2864,12 +2916,40 @@ export default function ComposeScreen({ route, navigation }: Props) {
/>
</SafeAreaView>
);
if (!isSplit) return composer;
// Tablet: a centred panel over the dimmed screen it was opened from.
return (
<View style={[styles.panelBackdrop, { paddingTop: insets.top + PANEL_MARGIN, paddingBottom: insets.bottom + PANEL_MARGIN }]}>
<View style={styles.panel}>{composer}</View>
</View>
);
}
const SELECTION_START = { start: 0, end: 0 };
/** Gap between the tablet composer panel and the screen edges. */
const PANEL_MARGIN = 24;
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
flex: { flex: 1 },
panelBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
alignItems: 'center',
paddingHorizontal: PANEL_MARGIN,
},
panel: {
flex: 1,
width: '100%',
maxWidth: 760,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: c.border,
overflow: 'hidden',
backgroundColor: c.background,
},
scrollContent: { flexGrow: 1 },
header: {
@@ -2882,6 +2962,7 @@ function makeStyles(c: ThemePalette) {
borderBottomColor: c.border,
gap: spacing.sm,
},
headerCompact: { paddingVertical: 0 },
headerBtn: {
width: componentSizes.buttonLg,
height: componentSizes.buttonLg,
@@ -2908,7 +2989,6 @@ function makeStyles(c: ThemePalette) {
fieldLabel: {
...typography.body,
color: c.textMuted,
width: 56,
paddingTop: 10,
},
fieldContent: {
+38 -22
View File
@@ -6,6 +6,7 @@ import {
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useInPane } from '../navigation/pane';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
@@ -32,6 +33,8 @@ import AddressBookPickerSheet from '../components/contacts/AddressBookPickerShee
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
@@ -130,6 +133,7 @@ export default function ContactDetailScreen() {
const navigation = useNavigation<Nav>();
const route = useRoute<Route>();
const t = useLocaleStore((s) => s.t);
const inPane = useInPane();
const { contactId } = route.params;
const contact = useContactsStore((s) => s.contacts.find((c) => c.id === contactId));
@@ -145,6 +149,17 @@ export default function ContactDetailScreen() {
const [moreOpen, setMoreOpen] = React.useState(false);
const [moveOpen, setMoveOpen] = React.useState(false);
const [groupPickerOpen, setGroupPickerOpen] = React.useState(false);
// Every hook runs before the not-found return: deleting the contact (or a
// sync removing it) re-renders this screen without it, and a hook that only
// ran with a contact crashed React with "fewer hooks than expected".
const members = contact?.members;
const memberContacts = React.useMemo(() => {
if (!members) return [];
const memberIds = Object.keys(members).filter((k) => members[k]);
return memberIds
.map((mid) => allContacts.find((c) => c.id === mid || c.uid === mid))
.filter(Boolean) as ContactCard[];
}, [members, allContacts]);
if (!contact) {
return (
@@ -213,14 +228,6 @@ export default function ContactDetailScreen() {
return 0;
})();
const memberContacts = React.useMemo(() => {
if (!contact.members) return [];
const memberIds = Object.keys(contact.members).filter((k) => contact.members![k]);
return memberIds
.map((mid) => allContacts.find((c) => c.id === mid || c.uid === mid))
.filter(Boolean) as ContactCard[];
}, [contact.members, allContacts]);
const openMail = (addr: string) => {
navigation.navigate('Compose', {
prefillTo: [{ name, email: addr }],
@@ -354,17 +361,20 @@ export default function ContactDetailScreen() {
].filter(Boolean) as MoreItem[];
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable
onPress={() => navigation.goBack()}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
<View style={[styles.header, inPane && styles.headerInPane]}>
{/* In a split view's pane the list is right next to it: no back. */}
{!inPane && (
<Pressable
onPress={() => navigation.goBack()}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} />
</Pressable>
)}
<Text style={styles.headerTitle} numberOfLines={1}>
{isGroup(contact) ? t('contacts.group', 'Group') : t('contacts.contact', 'Contact')}
</Text>
@@ -785,6 +795,8 @@ function MoreActionsSheet({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
@@ -802,11 +814,11 @@ function MoreActionsSheet({
}, [visible, slideY, overlayOpacity]);
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']}>
<View style={styles.handleHit}>
<View style={styles.handle} />
@@ -904,7 +916,8 @@ function makeStyles(c: ThemePalette) {
container: { flex: 1, backgroundColor: c.background },
scrollContent: { paddingBottom: spacing.xxxl * 2 },
header: {
headerInPane: { paddingLeft: spacing.lg },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
@@ -974,6 +987,9 @@ function makeStyles(c: ThemePalette) {
},
sectionIcon: { width: 16 },
sectionLabel: {
// Fill the row: shrink-wrapped, Android measured the letter-spaced
// label a few px short and wrapped its last word out of sight ("WOR").
flex: 1,
...typography.bodyMedium,
color: c.textSecondary,
textTransform: 'uppercase',
+71 -11
View File
@@ -4,7 +4,7 @@ import {
KeyboardAvoidingView, Platform, Modal, Image,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import { usePreventRemove, useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as ImagePicker from 'expo-image-picker';
@@ -31,6 +31,7 @@ import ContactPickerSheet from '../components/contacts/ContactPickerSheet';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactForm'>;
type Route = RouteProp<RootStackParamList, 'ContactForm'>;
@@ -746,6 +747,15 @@ export default function ContactFormScreen() {
const [saving, setSaving] = React.useState(false);
const [dirty, setDirty] = React.useState(!!prefill || !!initialMemberIds?.length);
const [confirmDiscard, setConfirmDiscard] = React.useState(false);
// Return walks the name fields in reading order, then on to the email.
const prefixRef = React.useRef<TextInput>(null);
const suffixRef = React.useRef<TextInput>(null);
const givenRef = React.useRef<TextInput>(null);
const middleRef = React.useRef<TextInput>(null);
const surnameRef = React.useRef<TextInput>(null);
const orgRef = React.useRef<TextInput>(null);
const fullRef = React.useRef<TextInput>(null);
const firstEmailRef = React.useRef<TextInput>(null);
const [datePickerIndex, setDatePickerIndex] = React.useState<number | null>(null);
const [memberPickerOpen, setMemberPickerOpen] = React.useState(false);
@@ -761,9 +771,25 @@ export default function ContactFormScreen() {
setForm((f) => ({ ...f, [key]: value }));
};
// Every way out asks about unsaved changes: the header back, Android's
// back and the modal's swipe-down. Only the header back did before.
const allowLeaveRef = React.useRef(false);
const pendingLeaveRef = React.useRef<(() => void) | null>(null);
usePreventRemove(dirty, ({ data }) => {
if (allowLeaveRef.current) {
navigation.dispatch(data.action);
return;
}
pendingLeaveRef.current = () => navigation.dispatch(data.action);
setConfirmDiscard(true);
});
const leave = (go: () => void) => {
allowLeaveRef.current = true;
go();
};
const handleBack = () => {
if (dirty) setConfirmDiscard(true);
else navigation.goBack();
navigation.goBack();
};
const handleSave = async () => {
@@ -833,11 +859,11 @@ export default function ContactFormScreen() {
const patch = formToPatch(form, existing, !!asGroup || existing?.kind === 'group', allContacts);
if (isEdit && existing) {
await updateContact(existing.id, patch);
navigation.goBack();
leave(() => navigation.goBack());
} else {
const created = await createContact(patch, form.addressBookId);
if (asGroup) navigation.replace('GroupDetail', { groupId: created.id });
else navigation.replace('ContactDetail', { contactId: created.id });
if (asGroup) leave(() => navigation.replace('GroupDetail', { groupId: created.id }));
else leave(() => navigation.replace('ContactDetail', { contactId: created.id }));
}
} catch (err) {
Alert.alert(
@@ -1039,16 +1065,21 @@ export default function ContactFormScreen() {
confirmText={t('settings.discard', 'Discard')}
onConfirm={() => {
setConfirmDiscard(false);
navigation.goBack();
const go = pendingLeaveRef.current ?? (() => navigation.goBack());
pendingLeaveRef.current = null;
leave(go);
}}
onCancel={() => {
pendingLeaveRef.current = null;
setConfirmDiscard(false);
}}
onCancel={() => setConfirmDiscard(false)}
/>
);
if (asGroup) {
// Groups are a name plus a member list - none of the person fields apply.
return (
<SafeAreaView style={styles.container} edges={['top']}>
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
{header}
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
@@ -1109,7 +1140,7 @@ export default function ContactFormScreen() {
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
{header}
<KeyboardAvoidingView
@@ -1181,6 +1212,10 @@ export default function ContactFormScreen() {
<TextInput
style={styles.input}
placeholder={t('contacts.form.organization_placeholder', 'Company name')}
ref={orgRef}
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => fullRef.current?.focus()}
placeholderTextColor={c.textMuted}
value={form.orgs[0]?.name || ''}
onChangeText={(v) => {
@@ -1197,6 +1232,10 @@ export default function ContactFormScreen() {
<TextInput
style={styles.input}
placeholder={t('contacts.form.prefix_placeholder', 'Dr., Mr., Mrs.')}
ref={prefixRef}
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => suffixRef.current?.focus()}
placeholderTextColor={c.textMuted}
value={form.prefix}
onChangeText={(v) => updateForm('prefix', v)}
@@ -1206,6 +1245,10 @@ export default function ContactFormScreen() {
<TextInput
style={styles.input}
placeholder={t('contacts.form.suffix_placeholder', 'Jr., Sr., III')}
ref={suffixRef}
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => givenRef.current?.focus()}
placeholderTextColor={c.textMuted}
value={form.suffix}
onChangeText={(v) => updateForm('suffix', v)}
@@ -1216,6 +1259,10 @@ export default function ContactFormScreen() {
<TextInput
style={styles.input}
placeholder={t('contacts.form.given_name', 'First name')}
ref={givenRef}
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => middleRef.current?.focus()}
placeholderTextColor={c.textMuted}
value={form.given}
onChangeText={(v) => updateForm('given', v)}
@@ -1225,6 +1272,10 @@ export default function ContactFormScreen() {
<TextInput
style={styles.input}
placeholder={t('contacts.form.middle_name', 'Middle name')}
ref={middleRef}
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => surnameRef.current?.focus()}
placeholderTextColor={c.textMuted}
value={form.middle}
onChangeText={(v) => updateForm('middle', v)}
@@ -1234,6 +1285,10 @@ export default function ContactFormScreen() {
<TextInput
style={styles.input}
placeholder={t('contacts.form.surname', 'Last name')}
ref={surnameRef}
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => fullRef.current?.focus()}
placeholderTextColor={c.textMuted}
value={form.surname}
onChangeText={(v) => updateForm('surname', v)}
@@ -1276,6 +1331,10 @@ export default function ContactFormScreen() {
<TextInput
style={styles.input}
placeholder={t('contacts.form.display_name_placeholder', 'Custom display name')}
ref={fullRef}
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => firstEmailRef.current?.focus()}
placeholderTextColor={c.textMuted}
value={form.full}
onChangeText={(v) => updateForm('full', v)}
@@ -1293,6 +1352,7 @@ export default function ContactFormScreen() {
<TextInput
style={styles.input}
placeholder={t('contacts.form.email_placeholder', 'email@example.com')}
ref={i === 0 ? firstEmailRef : undefined}
placeholderTextColor={c.textMuted}
keyboardType="email-address"
autoCapitalize="none"
@@ -1802,7 +1862,7 @@ export default function ContactFormScreen() {
};
if (Platform.OS === 'ios') {
return (
<Modal transparent animationType="fade" onRequestClose={() => setDatePickerIndex(null)}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setDatePickerIndex(null)}>
<Pressable style={styles.pickerOverlay} onPress={() => setDatePickerIndex(null)} />
<View style={styles.pickerSheet}>
<View style={styles.pickerHeader}>
+35 -4
View File
@@ -16,6 +16,8 @@ import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { useBackWhileFocused } from '../lib/use-back-while-focused';
import { listBackStep } from '../lib/list-back';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput, Upload, CheckSquare, Share2,
@@ -101,7 +103,14 @@ function categoryTitle(
}
}
export default function ContactsScreen() {
interface ContactsScreenProps {
/** Split view: open the contact in the detail pane instead of the stack. */
onOpenContact?: (contact: ContactCard) => void;
/** The contact open in the detail pane. */
activeContactId?: string | null;
}
export default function ContactsScreen({ onOpenContact, activeContactId = null }: ContactsScreenProps = {}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
@@ -210,7 +219,9 @@ export default function ContactsScreen() {
toggleSelect(c.id);
return;
}
if (isGroup(c)) {
if (onOpenContact) {
onOpenContact(c);
} else if (isGroup(c)) {
navigation.navigate('GroupDetail', { groupId: c.id });
} else {
navigation.navigate('ContactDetail', { contactId: c.id });
@@ -223,6 +234,17 @@ export default function ContactsScreen() {
const clearSelection = () => setSelection(new Set());
// Back leaves selection mode, then the search, before it leaves the tab.
const backStep = listBackStep({ selecting: selectionMode, searching: searchActive || searchQuery.length > 0 });
useBackWhileFocused(backStep !== 'leave', () => {
if (backStep === 'clear-selection') {
clearSelection();
} else {
setSearchQuery('');
setSearchActive(false);
}
});
const doBulkDelete = async () => {
const ids = Array.from(selection);
setConfirmBulkDelete(false);
@@ -359,7 +381,7 @@ export default function ContactsScreen() {
}, [addressBooks, groups, selectedCategory, t]);
return (
<SafeAreaView style={styles.container} edges={['top']}>
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
<ContactsSidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
<View style={styles.header}>
@@ -514,6 +536,7 @@ export default function ContactsScreen() {
key={chip.key}
onPress={() => setSelectedCategory(chip.category)}
style={[styles.chip, chip.active && styles.chipActive]}
hitSlop={{ top: 6, bottom: 6 }}
accessibilityRole="button"
accessibilityState={{ selected: chip.active }}
>
@@ -541,6 +564,7 @@ export default function ContactsScreen() {
onPress={() => handleRowPress(item)}
onLongPress={() => handleLongPress(item)}
selected={selection.has(item.id)}
active={item.id === activeContactId}
selectionMode={selectionMode}
/>
)}
@@ -693,15 +717,22 @@ function makeStyles(c: ThemePalette) {
},
searchInput: { flex: 1, ...typography.body, color: c.text, paddingVertical: 0 },
chipBar: { flexGrow: 0, marginBottom: spacing.sm },
// flexShrink 0: in a short (landscape) window the column squeezed the
// horizontal scroller and cut the chips off at the bottom.
chipBar: { flexGrow: 0, flexShrink: 0, marginBottom: 2 },
// The vertical padding leaves room for the chips' slop, which makes them
// 48 dp targets (they were about 28 dp).
chipBarContent: {
paddingHorizontal: spacing.lg,
paddingVertical: 6,
gap: spacing.sm,
alignItems: 'center',
},
chip: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
minHeight: 36,
justifyContent: 'center',
borderRadius: radius.full,
backgroundColor: c.surface,
borderWidth: 1,

Some files were not shown because too many files have changed in this diff Show More