mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aaa479875 | ||
|
|
124c0258ad | ||
|
|
ccffd6b10d | ||
|
|
0d96d8d044 | ||
|
|
95c31631fb | ||
|
|
cf7275c9c3 | ||
|
|
e452f72a8a | ||
|
|
776447dd41 | ||
|
|
52eb18dc33 | ||
|
|
33dc3cd0dc | ||
|
|
72b4f4abac | ||
|
|
de1b955bfd | ||
|
|
c8434b0610 | ||
|
|
e19229a5c6 | ||
|
|
d7cee7ca78 | ||
|
|
b59d00f0b7 | ||
|
|
975ed08c82 | ||
|
|
76170648f5 | ||
|
|
509583e729 | ||
|
|
7b93265b4f | ||
|
|
b5c4d62bde | ||
|
|
c6cb44db61 | ||
|
|
0db4bf00aa | ||
|
|
e94b782f8e | ||
|
|
ae1ef5ba5e | ||
|
|
f891bf3abb | ||
|
|
af55e0acef | ||
|
|
1ebc5c66f7 | ||
|
|
0b3710265b | ||
|
|
56e1593646 | ||
|
|
e7857ef806 | ||
|
|
b3290c3202 | ||
|
|
f231d1c5c6 | ||
|
|
984c359bb4 | ||
|
|
50ab7145c4 | ||
|
|
8f68d24bdb | ||
|
|
f22e851c82 | ||
|
|
d4982f6932 | ||
|
|
275d674577 | ||
|
|
0460da51e5 | ||
|
|
90170ea0e7 | ||
|
|
cbc41c963b | ||
|
|
63eefd7700 | ||
|
|
d97571cf0f | ||
|
|
8403843e76 | ||
|
|
756ceaa436 | ||
|
|
99de74de93 | ||
|
|
46d30169b2 | ||
|
|
512a28c494 | ||
|
|
29c172f2af | ||
|
|
9e463d3a66 | ||
|
|
ab712f11ba | ||
|
|
053fde8d56 | ||
|
|
250c0a9c26 | ||
|
|
d62805f805 | ||
|
|
b9e6ba417d | ||
|
|
0087e4c8ff | ||
|
|
2470e99dc8 | ||
|
|
8ab82cdeb7 | ||
|
|
64a62b6306 | ||
|
|
f4ff07da7a | ||
|
|
2288d28e46 | ||
|
|
295e292e47 | ||
|
|
802474d3f2 | ||
|
|
bfa02f1177 | ||
|
|
4b29cdfd3c | ||
|
|
2a47913d70 | ||
|
|
e19885dc72 | ||
|
|
4816f0e08b | ||
|
|
ef1d4bc387 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.76",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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')}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')}
|
||||
>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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')}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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; } }');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user