Compare commits

...
83 Commits
Author SHA1 Message Date
Linus Rath 7108431dbb chore: update version number to 0.1.78 2026-10-15 16:58:08 +02:00
Linus Rath 744b17908d fix: open a settings page at its top instead of the list's scroll position 2026-10-15 15:36:59 +02:00
Linus Rath c7edae9ed3 fix: move a setting's control under its label when a long label leaves no room 2026-10-15 15:29:13 +02:00
Linus Rath c07b39a070 fix: keep device sync free of expo by registering device locales from i18n #75 2026-10-15 15:19:18 +02:00
Linus Rath 7e5e296271 fix: use the app switch for the sender override in compose 2026-10-15 15:16:32 +02:00
Linus Rath ad6dff79ba fix: draw contact form fields like the webmail and quiet the letter headers in the contact list 2026-10-15 15:16:32 +02:00
Linus Rath a4ca72fb3d fix: draw event and task fields like the webmail and use the app switch in the event form 2026-10-15 15:16:32 +02:00
Linus Rath 107eb807e3 chore: update version number to 0.1.77 2026-10-15 15:15:29 +02:00
Linus Rath e731357b89 fix: format dates in the device region for the app language #75 2026-10-15 15:15:19 +02:00
Linus Rath 9cf1d3e849 fix: draw the text field in dialogs like the other inputs 2026-10-15 15:04:01 +02:00
Linus Rath d3dcc4e830 fix: show folder roles, filter origins and app modes as outline badges 2026-10-15 15:04:01 +02:00
Linus Rath e0d2b190d3 fix: give the Files header the same title size as Mail and Contacts 2026-10-15 15:04:00 +02:00
Linus Rath b048eaca03 fix: tint unread mail and draw the mail search, subject, quick reply and thread buttons like the webmail 2026-10-15 15:04:00 +02:00
Linus Rath 6f515e15f7 feat: rework settings into the picked list, About and Storage & backup pages
The settings list now has the account card on top, title-case group
labels and 56 dp rows that show each pane's current value. About and
"About & Data" were one placeholder page with a letter logo, a fixed
name and a store link that pointed at GitHub releases.

- About: the real logo, the app name from app.config.js, the update
  state, a source link that a build can set with SOURCE_CODE_URL, and
  rows for the server, update checks (Android), open-source licences,
  privacy and a prefilled GitHub issue. iPhone updates link TestFlight.
- Storage & backup: usage card, offline mail, settings export/import
  and reset, and a Troubleshooting page for refresh and debug logging.
- Sender favicons moved to Appearance, like the webmail.
- The S/MIME and Plugins stub pages are gone; both are webmail plugins.

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

- the thread pager sizes its pages from its own width and snaps back to
  the open message after a rotation instead of showing half a neighbour
- compose and the thread view share one keyboard tracker; a rotation
  with the keyboard open closes it, since Android sends no new keyboard
  frame and the old portrait one blanked the whole landscape composer
- in a short window with the keyboard up the composer hides the format
  bar and tightens its header so a few lines of text stay visible
- inputs with suggestions or context around them opt out of Android's
  full-screen landscape keyboard
- month views fit whole rows to the real height, keep the month across a
  remount, and the month dropdown re-measures and caps its height
- the week grid's header follows the remounted list after a rotation
- the event sheet's actions stay on screen in landscape
- every RN Modal allows all orientations, or iOS forces portrait
2026-10-15 01:45:23 +02:00
Linus Rath 4b29cdfd3c feat: add window size classes for tablet and landscape layouts 2026-10-15 01:44:45 +02:00
Linus Rath 2a47913d70 docs: add native ui and ux audit 2026-10-15 01:19:00 +02:00
186 changed files with 6952 additions and 2394 deletions
+34 -36
View File
@@ -1,13 +1,14 @@
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,
} from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
import { Mail, Calendar, BookUser, HardDrive, Settings } from './src/components/icons';
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
import { jmapClient } from './src/api/jmap-client';
@@ -43,10 +44,10 @@ import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
import EmailSourceScreen from './src/screens/EmailSourceScreen';
import LoginScreen from './src/screens/LoginScreen';
import EmailListScreen from './src/screens/EmailListScreen';
import MailTab from './src/screens/MailTab';
import FilesScreen from './src/screens/FilesScreen';
import CalendarScreen from './src/screens/CalendarScreen';
import ContactsScreen from './src/screens/ContactsScreen';
import ContactsTab from './src/screens/ContactsTab';
import ContactDetailScreen from './src/screens/ContactDetailScreen';
import ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen';
@@ -57,12 +58,12 @@ import { useAccountStore } from './src/stores/account-store';
import { useAuthStore } from './src/stores/auth-store';
import { useCalendarStore } from './src/stores/calendar-store';
import { useContactsStore } from './src/stores/contacts-store';
import { useEmailStore, viewerParamsForRow } from './src/stores/email-store';
import { mailboxAccountId } from './src/lib/mailbox-tree';
import { useEmailStore } from './src/stores/email-store';
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
import { useSettingsStore } from './src/stores/settings-store';
import { useLocaleStore } from './src/stores/locale-store';
import { useLayout } from './src/lib/use-layout';
import { toast } from './src/stores/toast-store';
import { useNetworkStore } from './src/stores/network-store';
import { useUpdatesStore } from './src/stores/updates-store';
@@ -243,9 +244,15 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
settings: t('sidebar.settings', 'Settings'),
};
const unavailable = (name: string) => t('sidebar.tab_unavailable', '{name} (unavailable)', { name });
// Side by side layouts move the tabs into a rail on the left, like the
// webmail's navigation rail.
const { isSplit } = useLayout();
// A bottom tab bar keeps the screens clear of the gesture bar or taskbar;
// with the tabs in a side rail the whole tab area has to.
const bottomInset = useSafeAreaInsets().bottom;
return (
<View style={{ flex: 1, backgroundColor: c.background }}>
<View style={{ flex: 1, backgroundColor: c.background, paddingBottom: isSplit ? bottomInset : 0 }}>
<UpdateBanner />
<OfflineCacheBanner />
<PushOnboardingPrompt />
@@ -255,9 +262,21 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
headerShown: false,
// Tabs out of view stop re-rendering until they are shown again.
freezeOnBlur: true,
tabBarPosition: isSplit ? 'left' : 'bottom',
tabBarVariant: isSplit ? 'material' : 'uikit',
// A narrow rail (icon over label), not a wide sidebar with labels
// beside the icons.
...(isSplit ? { tabBarLabelPosition: 'below-icon' as const } : null),
tabBarActiveTintColor: c.text,
tabBarInactiveTintColor: c.textSecondary,
tabBarStyle: {
tabBarStyle: isSplit ? {
backgroundColor: c.secondary,
borderRightColor: c.border,
borderRightWidth: 1,
elevation: 0,
shadowOpacity: 0,
shadowColor: 'transparent',
} : {
backgroundColor: c.background,
borderTopColor: c.border,
borderTopWidth: 1,
@@ -291,30 +310,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
},
}}
>
{() => (
<EmailListScreen
onComposePress={() => navigation.navigate('Compose')}
onEmailPress={(email) => {
// The list holds the open folder's mail: name its account so the
// viewer never has to guess it from whatever folder is open.
const { mailboxes: all, currentMailboxId } = useEmailStore.getState();
const target = {
jmapAccountId: mailboxAccountId(all, currentMailboxId),
// A row of an "All folders" list or a tag view names its own
// account instead, and pages over that account's rows.
...viewerParamsForRow(email),
};
// Start on the body now: mounting the viewer takes a while.
prefetchMessage(email, target.jmapAccountId);
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject,
...target,
});
}}
/>
)}
{() => <MailTab navigation={navigation} />}
</Tab.Screen>
<Tab.Screen
name="Calendar"
@@ -333,7 +329,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
/>
<Tab.Screen
name="Contacts"
component={ContactsScreen}
component={ContactsTab}
options={{
title: tabLabels.contacts,
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
@@ -402,6 +398,7 @@ export default function App() {
// cached mail list instead of the "Restoring session" spinner; the real
// JMAP session comes up in the background.
const hasPersistedAccount = useAccountStore((state) => state.activeAccountId != null);
const { isSplit } = useLayout();
React.useEffect(() => {
if (!hasRestoredSession) {
@@ -832,10 +829,11 @@ export default function App() {
<Stack.Screen
name="Compose"
component={ComposeScreen}
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
}}
// A tablet shows the composer as a panel over the screen it came
// from, so that screen stays drawn behind the dimmed backdrop.
options={isSplit
? { presentation: 'transparentModal', animation: 'fade' }
: { presentation: 'modal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
<Stack.Screen
+1 -1
View File
@@ -1 +1 @@
0.1.75
0.1.78
+1 -1
View File
@@ -25,7 +25,7 @@
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
+4 -1
View File
@@ -31,7 +31,7 @@ module.exports = {
// Android's manifest is maintained by hand and lists the same schemes.
scheme: ['bulwarkmobile', 'bulwarkmail', 'mailto'],
version: VERSION,
orientation: 'portrait',
orientation: 'default',
icon: './assets/icon.png',
userInterfaceStyle: 'automatic',
newArchEnabled: true,
@@ -99,6 +99,9 @@ module.exports = {
],
extra: {
commit: COMMIT,
// Settings → About → Source code. A modified build sets SOURCE_CODE_URL
// to where its own source is (AGPL §13); the default is upstream.
sourceCodeUrl: process.env.SOURCE_CODE_URL || 'https://github.com/bulwarkmail/native',
},
},
};
-1
View File
@@ -10,6 +10,5 @@ module.exports = function babelConfig(api) {
api.cache(true);
return {
presets: [presetExpo],
plugins: [require.resolve('./scripts/babel-plugin-lucide-imports')],
};
};
+649
View File
@@ -0,0 +1,649 @@
# Native UI and UX audit, 2026-10
Scope: every screen, sheet, dialog, banner and gesture of the Android app, on a phone and a tablet, in
portrait and landscape. Started from `e19885d` (0.1.75).
## Method
- Devices (emulators):
- phone `Bulwark_Pixel7_API35`, 411x914 dp;
- tablet `Pixel_Tablet_API_34`, 1280x800 dp.
The tablet's natural orientation is landscape, so `user_rotation 1` is tablet portrait.
- Cells used below:
| Cell | Device and orientation |
|---|---|
| PP | phone portrait |
| PL | phone landscape |
| TL | tablet landscape |
| TP | tablet portrait |
"all" means all four.
- Build: release APK from `C:\rn`, with the portrait lock removed for the audit only.
- Data: Stalwart 0.16 on VM 106, seeded with:
- 520+ inbox mails, a 16-message thread, HTML-only newsletters, a wide table, all attachment types
and an inline cid image;
- RTL mail (Arabic, Hebrew), a read-receipt request, very long subjects and names;
- nested folders;
- recurring, all-day, multi-day, overlapping, declined and cancelled events, and tasks;
- 65 contacts with and without photos, plus two groups;
- files and folders, and an active vacation reply.
The phone ran as `usera@`, the tablet as a separate `uxaudit@` account with the same seed.
- Every area was captured in all four cells in light English (tablet) and dark English (phone).
Then a second pass ran with German and font scale 1.3, on PP and TL, with the tablet in dark.
- Four read-only code passes (mail list, thread and compose, calendar, contacts/files/settings/login)
gave the `file:line` references. Device findings were checked against them.
- Screenshots are in the session scratchpad (`shots/q_<screen>.png`, a 4-cell strip: PP, PL, TL, TP;
`shots/d_<screen>.png` for the German/1.3 pass). They are not committed.
## Inventory
### Navigation
- Root native stack (`App.tsx`):
- MainTabs
- EmailThread
- EmailSource
- Compose (modal)
- ContactDetail
- ContactForm (modal)
- GroupDetail
- Scheduled
- UnifiedInbox
- AddAccount (modal)
- Bottom tabs: Mail, Calendar, Contacts, Files, Settings. A tab the server lacks is dimmed and does
nothing.
- Above the tabs:
- UpdateBanner
- OfflineCacheBanner
- PushOnboardingPrompt (floating card)
- AppIconBadge (no UI)
- On the focused stack screen: ToastHost and UndoSnackbar (none on Compose).
- Entry points:
- deep links: `bulwarkmobile://`, webmail permalinks, `mailto:`, and sign-in links
(`bulwarkmail://pair`, `connect`);
- Android share intents into Compose;
- notification taps (mail push, calendar reminders);
- widgets (inbox, agenda), which deep-link.
### Login (`LoginScreen` + `screens/login/*`)
- Steps: choose → email → (discovery) confirm or server → confirm → OAuth hand-off in the browser, or
password (+ TOTP when the server asks) → signing in.
- Side paths:
- QR scan modal (camera permission, blocked → open settings);
- paste-link modal;
- external sign-in link confirm alert;
- refused-link notice;
- Add account mode with a close button.
### Mail
- **Mail list** (`EmailListScreen`):
- Header: menu, folder title, import, logo.
- Search row: select-all, search with recent searches and people suggestions, sort toggle,
filter.
- Filter chips.
- Banners: empty folder, offline, failed outbox, unreachable accounts.
- States: loading, error + retry, no mailboxes, empty / no results.
- Gestures: pull to refresh, endless scroll, long-press to select, swipe actions (instant or
reveal).
- Selection header: star, read, tag, move, spam, archive, delete.
- Compose FAB.
- Sheets: filter modal (with date pickers), MoveSheet, TagSheet, attachment preview.
- **Sidebar drawer** (`SidebarDrawer`):
- Account switcher and account menu: add, set default, sign out, sign out of all.
- Unified Inbox, Scheduled.
- Unified Mailbox section, folders tree with unread counts and long-press folder sheet, shared
accounts, tags.
- Nested ActionSheet and NamePrompt.
- **Unified inbox** (`UnifiedInboxScreen`): its own header, search, selection, swipe, account dots,
pull to refresh, load more.
- **Scheduled** (`ScheduledScreen`): list, per-item actions (send now, reschedule, edit, cancel),
reschedule presets, custom date-time picker.
### Thread view (`EmailThreadScreen` + `components/email/*`)
- Layout:
- Horizontal pager over the list.
- Toolbar: back, delete, archive (≥400 dp), unread (≥340 dp), star, more.
- Subject with star.
- Single message, or conversation cards (collapsed rows, expanded cards with Reply / Reply all /
Forward pills).
- Message header: sender and recipients open the address sheet; "Show details".
- Attachments: preview, open, save and share; "Download all".
- Banners: blocked images, truncated body, read receipt, unsubscribe, calendar invitation (RSVP,
add to calendar).
- Quick reply box.
- Bottom bar: Prev, three configurable actions, Next.
- More sheet: archive, read, move, tag, spam, view mode, sender, forward as attachment, source,
export.
- Email source screen.
### Compose (`ComposeScreen`)
- Header: close, title + draft status, attach, schedule, send.
- Fields:
- From (IdentitySheet, From options);
- To / Cc / Bcc with chips, autocomplete and a chip menu;
- Subject.
- Attachments: sheet with photos, camera, files; chips with progress.
- Body: rich editor WebView or plain text.
- Format bar: templates, read receipt, plain/rich, B/I/U/S, colours, H1/H2, lists, quote, table,
alignment, link, image, clear, undo/redo.
- Modals: link, colour palette, table, schedule sheet, date-time pickers, template placeholders,
TemplateSheet.
- Flows:
- draft autosave;
- close guard (cancel / discard / save draft);
- empty-subject and forgotten-attachment checks;
- undo send through UndoSnackbar;
- scheduled-send confirmation.
### Calendar (`CalendarScreen` + `components/calendar/*`)
- Views: month (free scroll or paged), week (free scroll or paged), 3 days, day, schedule (agenda).
- Header: menu, month title with month dropdown, prev/next (paged only), today, tasks.
- FAB: event, or a menu with Event / Task.
- Sheets and dialogs:
- EventDetailSheet: RSVP, edit, duplicate, .ics, link, delete.
- EventModal (editor): RecurrenceEditor, ParticipantInput, pickers.
- RecurrenceScopeDialog.
- CalendarSidebarDrawer: views, refresh, calendars with a long-press panel, new calendar,
subscriptions, import.
- TasksSheet, ICalImportSheet, ICalSubscriptionSheet, CalendarEditSheet, CalendarShareSheet.
- Gestures:
- tap a slot for a draft, then tap the draft to create;
- long-press a slot to create;
- tap a month cell to open the day;
- free scroll with snapping.
- There is no drag-to-move or drag-to-resize, so there is nothing to break mid-drag.
### Contacts
- `ContactsScreen`:
- Header: drawer, count, search, import, add (long-press: contact or group).
- Address book / group chips.
- Long-press to select.
- Selection header: select all, export, move, tag, delete.
- Pull to refresh.
- `ContactsSidebarDrawer`: books, shared books, groups, tags with rename.
- `ContactDetailScreen`:
- hero and quick actions (email, call, SMS, share);
- sections: email, phone, address, work, personal, online, keys, categories, related, members,
notes, address book;
- recent mail and events;
- More sheet (duplicate, add to group, move, export, delete).
- `ContactFormScreen`: person, organisation or group, with many collapsible sections and a date
picker.
- `GroupDetailScreen`: members, add or remove.
- Sheets: AddressBookPicker, TagAssign, ContactPicker, ContactImport.
### Files (`FilesScreen`)
- Header: back, search, new folder, upload, grid/list.
- Breadcrumb, sort chip, upload progress.
- List or 3-column grid; long-press to select (download zip, delete).
- Per-item ⋮ sheet: preview, open with, download, share, move, duplicate, rename, delete.
- Folder picker, sort sheet, prompt modal, share sheet, preview modal.
### Settings (`SettingsScreen` + `components/settings/*`)
- One screen that swaps between a searchable section list and a page.
- Pages:
- account, language, notifications
- appearance, layout
- reading, composing, identities, vacation, filters (rule modal, sieve editor), templates,
folders, tags, downloads
- security, encryption (disabled), content & senders
- calendar and contacts (with device sync), files, sidebar apps
- about & data, themes, plugins, updates
- Sign out.
### Other
- PushOnboardingPrompt, OfflineBanner, OfflineCacheBanner, UpdateBanner.
- Toasts: swipe to dismiss, action button.
- UndoSnackbar: undo, send now.
- Android widgets (out of scope for fixes): inbox and agenda.
## Findings
Severity: **P1** broken or blocking, **P2** clearly wrong or clumsy, **P3** polish. Each finding
lists the cells where it occurs, the screenshot, the code location and the intended fix. A tick
carries the commit that fixed it.
### Cross-cutting
- [x] **U-001 · P1 · PL TL TP · The app is locked to portrait and has no large-screen layout.** *(fixed in bfa02f1)*
- Where: `app.config.js:34`, `AndroidManifest.xml:28`.
- Android 16 ignores the lock on screens 600 dp and wider, so tablets and foldables rotate anyway.
- Fix: remove the lock in both places. Keep `configChanges` (and add `smallestScreenSize` for
split-screen and foldables). Then do phase 2.
- [x] **U-002 · P2 · iPad, landscape · No RN `<Modal>` sets `supportedOrientations`.** *(fixed in bfa02f1)*
- Where: 56 modals in 40 files.
- iOS presents these portrait-only and forces a rotation.
- Fix: pass all orientations everywhere.
- [x] **U-003 · P2 · TL TP · Every screen is the phone layout stretched to 1280 dp.** *(fixed in 802474d, 64a62b6, 8ab82cd, 2470e99, 0087e4c, 250c0a9)*
- Affected: mail rows, thread body, compose, contacts, files, settings, login.
- Screenshots: `q_mail_list`, `q_thread_receipt`, `q_contacts`, `q_settings`, `t_login_confirm`.
- Fix: the adaptive layout plan below.
- [x] **U-004 · P2 · TL TP · Sheets and floating cards span the full width.** *(fixed in f4ff07d)*
- Affected: bottom sheets, toasts, the undo bar and the push prompt.
- Screenshots: `q_event_detail`, `q_mail_list` (push prompt).
- Fix: on medium and expanded widths, cap them at about 560 dp and centre them.
- [x] **U-005 · P2 · PL · Left and right safe-area insets are ignored.** *(fixed in bfa02f1, f4ff07d, e19229a)* *(partly fixed in bfa02f1 (compose), f4ff07d (sheets))*
- Most screens use `SafeAreaView edges={['top']}` and position sheets `left: 0, right: 0`.
- A side navigation bar or a camera cutout covers the content.
- Fix: add the side edges to screen roots, and inset sheets and the FABs.
- [x] **U-006 · P2 · all · The Files header pads a fixed 60 px instead of the status-bar inset.** *(fixed in cbc41c9)*
- Where: `FilesScreen.tsx:1438`.
- It is too tall in landscape and wrong on any other status bar height.
- [x] **U-007 · P2 · all · Uppercase section labels get clipped on Android.** *(fixed in 9e463d3)*
- Example: "WORK" shows as "WOR", "ADDRESS BOOK" as "ADDRESS" (contact detail, English).
- Cause: Android measures a shrink-wrapped, letter-spaced label a few pixels short, so its
last word wraps onto a second line that the one-line height hides. (Upper-casing the string
instead of `textTransform` made no difference.)
- Where: `ContactDetailScreen.tsx:976`, `ContactActivity.tsx:283`.
- Fix: let the label fill its row (`flex: 1`).
- [x] **U-008 · P2 · all · After a cold start in German, the tab labels and the folder title stay
English.** *(fixed in 512a28c)* The search placeholder is German. Screenshot `t_layo`.
- Fix: re-render the tab labels and the title when the locale store hydrates.
- [x] **U-009 · P2 · TL TP (font 1.3) · Tab labels truncate with plenty of room.** *(fixed in 802474d)*
- Example: "E-…", "Kalen…", "Einstellu…" in 256 dp-wide tabs (`q_de_mail`).
- The label sits beside the icon with a fixed font size and no width.
- Fix: let the label size itself; on wide screens use the side rail (phase 2).
- [x] **U-010 · P2 · all (font 1.3) · The mail search field clips the top of its text.** *(fixed in 7b93265)*
- Where: fixed height in `EmailListScreen.tsx:1885-1912`.
- Fix: use `minHeight` and vertical padding.
- [x] **U-011 · P2 · dark · The dark palette doesn't match the webmail's standard theme.** *(fixed in b3290c3)*
- The app uses a blue primary and zinc greys. The webmail uses a white `#fafafa` primary with
`#171717` text, a `#0a0a0a` ground, neutral greys, `#1c1c1c` popover and a `#262626` muted
surface.
- Where: `src/theme/tokens.ts` `DARK_COLORS`. APP.md lists this as open.
- Fix: port the webmail dark tokens.
- [x] **U-012 · P2 · light · The mail header logo is invisible on the light theme.** *(fixed in 99de74d)*
- It always loads `Bulwark Logo White.png` (`EmailListScreen.tsx:1105`). Screenshot `q_mail_list`
TL.
- Fix: pick the logo by the resolved theme.
- [x] **U-013 · P2 · light · TagSheet reads the static dark palette.** *(fixed in 29c172f)*
- Where: `TagSheet.tsx:7,101,124`. Tag colours are wrong on light.
- Fix: use `c.tags`.
- [x] **U-014 · P2 · all · `Button` has no `accessibilityRole` and no disabled or busy state.** *(fixed in f891bf3)*
- Where: `Button.tsx:35`. This affects every dialog and prompt button.
- [x] **U-015 · P2 · all · Many icon buttons are under 48 dp.** *(fixed in c8434b0,776447d)* *(partly fixed in c8434b0 (thread back, login header, Files header and row menu, account icons, switches, drawer chevron and folder icon, search clear, recipient chip, quick reply))*
- Mail search clear 36, recent-search remove 28, filter chip remove 32, attachment chips about 22,
drawer chevron 32.
- Viewer: card pills about 36, collapse handle 18, quick-reply Send 28, subject star 34.
- Compose: recipient chip X 28, Send 36, Cc/Bcc toggles 28.
- Files: header 44, row ⋮ 26.
- Settings: account icon buttons 28, toggle 24 tall.
- Login: header 32, show-password 20.
- Sheet close buttons are 28 everywhere.
- Lists with `file:line` are in the code passes; the fix commits name the places.
- Fix: a shared 48 dp target (box plus `hitSlop`), applied screen by screen.
- [ ] **U-016 · P3 · all · Sheet and dialog scrims are a literal `rgba(0,0,0,0.5)` in about 20
files.** *(deferred: all scrims are already the same black at 0.5 on both themes; a token is a refactor with no visible change)* Fix: an `overlay` token.
- [ ] **U-017 · P3 · all · Some confirmations use the system alert, others the app's `Dialog`.** *(deferred: switching every confirmation to Dialog changes a lot of flows for a style difference only; see product questions)*
Examples: compose discard, event delete, folder empty. This is a style mismatch.
- [ ] **U-018 · P3 · all · Changing the system font size while the app is open recreates the activity
and resets navigation to the mail list.** *(deferred: handling fontScale in the app (configChanges) was tried and left a mix of old and new text sizes until a restart, worse than Android's default recreate; needs RN to re-measure on a font scale change)* Text measured before the change can stay clipped until
the next restart ("Layo" for "Layout").
### Mail list, drawer, unified inbox, scheduled
- [x] **U-020 · P2 · all · Back with an active search exits the app instead of clearing the
search.** *(fixed in 46d3016)*
- Repro: search "Offsite", press Enter, then Back.
- Where: `EmailListScreen.tsx` has no back handler for search or selection.
- Fix: `useBackWhileFocused` that clears the selection, then the search.
- [x] **U-021 · P2 · PL · Phone landscape shows about 1.5 rows.** *(fixed in b5c4d62)*
- The 56 dp header, the 56 dp search row and the 64 dp tab bar take most of the 411 dp.
- The compose FAB covers the second row. Screenshot `q_mail_list` PL.
- Fix: in compact height, merge the title and search into one row and drop the tab labels.
- [x] **U-022 · P2 · PL (font 1.3) · Rows cut the sender to three letters and clip the time.** *(no change needed: after a restart at 1.3 the rows keep the full time and name; the clipping was stale measurement from changing the font size live, see U-018)*
- Example: "Us…", "17:4" (`q_de_mail` PL).
- Where: the row header in `EmailListScreen.tsx` around 158-250.
- Fix: let the time keep its width (`flexShrink: 0`) and let the name shrink.
- [x] **U-023 · P2 · PP · The undo bar sits on top of the compose FAB for 5 s after every archive or
delete.** *(fixed in 50ab714)*
- Where: `UndoSnackbar.tsx:161`, `EmailListScreen.tsx:2262`.
- Fix: lift the FAB while the bar shows.
- [x] **U-024 · P2 · all · Swipe and bulk actions on the list fail silently.** *(fixed in 0db4bf0)*
- Archive, spam, read, star, move and delete are called as `void ...` with no catch, so a server
rejection becomes an unhandled promise. The selection also stays.
- Where: `EmailListScreen.tsx:604-651, 715-799`; `handleImport` `:492`.
- Fix: report failures through `toast.error` as the viewer does.
- [x] **U-025 · P2 · all · Sign out and "Sign out of all accounts" run with no confirmation.** *(fixed in 8403843)*
- Where: drawer `SidebarDrawer.tsx:930,943`, settings `SettingsScreen.tsx:497`.
- Fix: a destructive `Dialog`.
- [x] **U-026 · P2 · all · The drawer's folder-settings icon only closes the drawer.** *(fixed in d97571c)*
- Where: `SidebarDrawer.tsx:1048`, `navigate('MainTabs')`.
- Fix: open Settings → Folders.
- [x] **U-027 · P2 · all · The filter modal runs a query per keystroke and has no keyboard
handling.** *(fixed in c6cb44d)*
- Where: `EmailListScreen.tsx:1507-1550`.
- Fix: debounce like the main search; add next/search return keys.
- [x] **U-028 · P2 · PP PL · The unified inbox and Scheduled lists end under the gesture bar.** *(fixed in 984c359)*
- Where: `UnifiedInboxScreen.tsx:384`, `ScheduledScreen.tsx:278` (top edge only, fixed padding).
- [x] **U-029 · P2 · all · Unified inbox rows ignore the font-size, density and preview settings.** *(fixed in 52eb18d)*
- Where: `UnifiedInboxScreen.tsx:576`.
- Its load and open failures are silent (`:103-112`, `:179-206`).
- [x] **U-030 · P3 · all · The menu button keeps a grey focus box after the drawer closes.** *(no change needed: did not reproduce after the drawer and rail changes; the button shows no focus box after closing the drawer by tap or Back)*
Screenshot `q_search_results`.
- [x] **U-031 · P3 · PP · The selection header holds up to 7 buttons; its count has no
`numberOfLines`.** *(fixed in 0d96d8d)* Where: `EmailListScreen.tsx:982-1066`.
- [x] **U-032 · P3 · all · Folders under a hidden empty parent show flattened at the top level.** *(no change needed: the tree only lifts a folder whose parent the server does not return; usera has no Clients folder, and on uxaudit Q4 nests under Clients / ACME / 2026)*
- Example: "Q4 contracts" without Clients / ACME / 2026 (`q_drawer` PP).
- This happens with "hide empty folders" on. To check before changing anything.
- [x] **U-033 · P2 · TL TP · With the tabs in the side rail, the compose FAB and list ends sit under
the tablet taskbar.** *(fixed in b9e6ba4)* Found after the mail split view landed (802474d): a bottom tab bar used to
keep the screens clear of the bottom inset, a side rail doesn't.
- Fix: pad the tab area by the bottom inset when the rail is shown.
### Thread view
- [x] **U-040 · P1 · PL TL TP · After a rotation the pager is misaligned.** *(fixed in bfa02f1)*
- Half of the neighbouring page shows and the open message is cut off at the right edge.
- It happens when rotating on the thread, and when rotating on Compose opened from it
(`q_thread_long` PL, `q_compose_close` TL).
- Where: `EmailThreadScreen.tsx:265-280`; the pager keeps its old pixel offset.
- Fix: re-scroll to the active index after a width change, without animation.
- [x] **U-041 · P2 · all · A long thread opens at the oldest message.** *(fixed in 7617064)*
- The newest (opened) message is expanded but off screen on a phone (`q_thread_long` PP).
- Fix: scroll the opened message into view once the cards are laid out.
- [x] **U-042 · P1 · all · Quick reply "More options" throws away the typed text.** *(fixed in 053fde8)*
- Where: `EmailThreadScreen.tsx:1185`.
- Fix: pass the text as `prefillBody` to the reply composer.
- [x] **U-043 · P2 · PL · Phone landscape leaves almost no room for the body.** *(fixed in 975ed08)*
- Toolbar with labels, subject, sender block, attachments, banner and the labelled bottom bar
fill the 411 dp (`q_thread_receipt` PL).
- Fix: icon-only toolbar and bottom bar in compact height.
- [x] **U-044 · P2 · PP (German, 1.3) · The toolbar overflows and pushes More off screen.** *(fixed in 975ed08)*
- "Stern hinzufügen" is long (`d_de_thread`).
- Fix: drop the toolbar labels when they don't fit; keep the a11y labels.
- [ ] **U-045 · P3 · German · Prev/Next read "Zurück"/"Weiter".** *(deferred: the strings come from the vendored webmail catalog (locales/de/common.json); the fix belongs in the webmail's German translation)*
- "Zurück" reads as Back. "Allen antworten" wraps in the bottom bar.
- Fix: "Vorherige"/"Nächste" (locale), and a single line with shrinking text.
- [x] **U-046 · P2 · all · The tag sheet doesn't scroll.** *(fixed in b59d00f)*
- With many tags, or in landscape, rows can't be reached.
- Where: `EmailThreadScreen.tsx:1472-1486`.
- [x] **U-047 · P2 · TL TP · Plain-text and fluid HTML bodies run 150+ characters per line.** *(fixed in e94b782)*
- Fix: a readable max width for the body on wide panes.
- [x] **U-048 · P3 · all · The toolbar star always says "Star", even when the message is starred.** *(fixed in d7cee7c)*
- Where: `EmailThreadScreen.tsx:746`.
- [x] **U-049 · P3 · PL TL · Attachment image preview is sized from the window width, so it is
taller than the screen in landscape.** *(fixed in 2470e99)* Where: `AttachmentPreviewModal.tsx:112`.
### Compose
- [x] **U-060 · P1 · PL · With the keyboard open in landscape, the whole compose body is blank.** *(fixed in bfa02f1)*
- Only the header stays, so you can't see what you type (`tri.png`).
- Where: the keyboard padding is computed from `Dimensions.get('screen').height`
(`ComposeScreen.tsx:400-425`), which doesn't follow the rotation.
- Fix: compute the overlap from the window, against the keyboard frame.
- [x] **U-061 · P2 · all · An untouched reply autosaves a draft and asks "Save or discard?" on
close.** *(fixed in e7857ef)*
- The signature insertion counts as an edit (`d_de_compose`). Opening and closing a reply should
leave nothing behind.
- Where: `ComposeScreen.tsx:1116-1184`, `:1331-1444`.
- [x] **U-062 · P2 · all · Recipient and subject fields have no return-key flow.** *(fixed in 56e1593)*
- Fix: To/Cc/Bcc use `next`; an empty submit moves on; Subject `next` focuses the body.
- Where: `ComposeScreen.tsx:2228-2395`.
- [x] **U-063 · P2 · all (German, 1.3) · Field labels wrap.** *(fixed in 509583e)* "Betreff" shows as "Betref/f": the
label column is a fixed width.
- [x] **U-064 · P3 · PP · The subject field shows the end of a long subject.** *(fixed in 56e1593)*
- Example: "…ning 2027: venue…". The cursor was put at the end on mount.
- [x] **U-065 · P2 · all · Format-bar buttons have no accessibility labels.** *(fixed in af55e0a)*
- Where: `ComposeScreen.tsx:2441-2513`; the toolbar button also has no role or state.
- [x] **U-066 · P2 · PL · Text inputs use Android's full-screen landscape keyboard.** *(fixed in bfa02f1)*
- The autocomplete and the composer are hidden behind it.
- Fix: `disableFullscreenUI` on recipients, subject, plain body and quick reply.
- [x] **U-067 · P2 · TL TP · Compose fills the tablet screen.** *(fixed in 0087e4c)* Fix: a centred panel (phase 2).
- [x] **U-068 · P2 · all · The signature shows two "--" lines.** *(fixed in 90170ea)* The seeded text signature starts
with the usual "-- " delimiter and the composer adds its own separator above it (screenshot
`t_compose_panel`).
- Fix: don't add a separator to a signature that already starts with one.
- [ ] **U-069 · P2 · PL · Recipient suggestions can't show in phone landscape.** *(deferred: with the keyboard up a phone in landscape has about 100 dp left; suggestions need a different design (a full-screen picker), which is a feature)* With the keyboard
up, about 50 dp are left under the To field, so the suggestion list is hidden behind the
keyboard. Typing a full address still works.
### Calendar
- [x] **U-080 · P1 · PL · Month view shows two week rows and cuts off the rest.** *(fixed in bfa02f1)*
- Rows keep a 72 dp minimum and the list is sized to six of them (`q_calendar_default` PL).
- Where: `MonthScrollView.tsx:127-128,250`, `MonthView.tsx:427-438`.
- Fix: size the list to the real height and let rows shrink.
- [x] **U-081 · P1 · TL TP · Rotating on month or week jumps back a month, and week view goes
blank.** *(fixed in bfa02f1)*
- After a rotation, month view showed September instead of October. Week view showed no day
headers and no events until Today was tapped (`q_calendar_default` TP, `q_cal_week` TL).
- Where: `MonthScrollView` samples the visible month at a fixed 40% of a stale height;
`TimeGridScrollView` keeps the old `scrollX` after its list remounts.
- Fix: keep the focused date across the remount and reset the header offset.
- [x] **U-082 · P1 · PL · The event sheet's action row (Edit, Duplicate, .ics, Delete) is below the
screen.** *(fixed in bfa02f1)*
- Screenshot `p_event_land`.
- Where: `EventDetailSheet.tsx:609,643` (body `maxHeight: 400`, no `flexShrink`).
- [x] **U-083 · P1 · all · Saving an event fails silently.** *(fixed in ab712f1)*
- `handleSave` has `try/finally` without catch: the spinner stops, the editor stays, nothing is
said.
- Where: `EventModal.tsx:301-368`.
- [x] **U-084 · P2 · all · Several deletes have no confirmation or undo.** *(fixed in 0460da5)*
- Deleting a single event (`CalendarScreen.tsx:632`, `EventModal.tsx:371`).
- Deleting a task (`TasksSheet.tsx:427`).
- Unsubscribing from a feed, which also deletes its calendar (`ICalSubscriptionSheet.tsx:278`).
- [x] **U-085 · P2 · all · Cancelling the recurrence-scope dialog after editing a recurring event
throws the edits away.** *(fixed in 72b4f4a)* Where: `CalendarScreen.tsx:786-790`.
- [x] **U-086 · P2 · TL TP · Wide screens get wider columns, not more days.** *(fixed in 2288d28)* 3 days stays 3 and
day stays 1. Fix: phase 2.
- [x] **U-087 · P2 · PL · The month dropdown is a fixed 346 dp tall and sized from the window.** *(fixed in bfa02f1)*
- It overflows in landscape and lands on a half page after rotation.
- Where: `MonthDropdown.tsx:23,52,72`.
- [x] **U-088 · P3 · all · Times and dates use English formats in other languages.** *(fixed in cf7275c)*
- Hour labels hard-code AM/PM (`TimeGridScrollView.tsx:437,640`, `WeekView.tsx:234,318`).
- Dates use US-order patterns (`EventDetailSheet.tsx:96-108`, `EventModal.tsx:525,545`,
`TasksSheet.tsx:212`).
- The AM/PM labels only show with the 12-hour setting, where they are the right notation; left
as is.
- [x] **U-089 · P3 · all · ICalSubscriptionSheet and RecurrenceEditor are English only.** *(no change needed: both components already go through t() and every key has a German entry; the parity doc is out of date)* From
`docs/parity/05-calendar.md`.
- [x] **U-090 · P3 · PP (font 1.3) · Month chips show only the start time, no title.** *(fixed in 95c3163)*
- [x] **U-091 · P3 · all · An event that starts above the visible hours shows as an untitled
block.** *(no change needed: this is a block scrolled partly out of the top of the grid, so its title is above the viewport; Google Calendar and Apple Calendar behave the same)*
### Contacts
- [x] **U-100 · P1 · all · Deleting a contact or group crashes the detail screen.** *(fixed in 295e292)*
- Hooks run after an early return: `ContactDetailScreen.tsx:149` before `:216`, and
`GroupDetailScreen.tsx:58` before `:130`.
- The delete empties the store, the screen re-renders with fewer hooks, and React throws. A
contact removed by a sync does the same.
- [x] **U-101 · P2 · PL · The address-book chip row is clipped at the bottom.** *(fixed in 0b37102)* Screenshot
`q_contacts` PL.
- [x] **U-102 · P2 · all · Back doesn't leave selection or search; it leaves the tab.** *(fixed in 756ceaa)*
- Where: `ContactsScreen.tsx`. Files and Settings already handle this.
- [x] **U-103 · P2 · all · The contact form's discard check only covers the on-screen back.** *(fixed in 1ebc5c6)*
- Hardware back drops edits silently.
- Where: `ContactFormScreen.tsx:764`. Fix: `usePreventRemove`.
- [x] **U-104 · P2 · all · Contact form fields have no next or submit chaining.** *(fixed in 33dc3cd)* Where:
`ContactFormScreen.tsx:1197-1283`.
- [x] **U-105 · P2 · TL TP · List and detail are separate full-screen pages.** *(fixed in 64a62b6)* Fix: phase 2.
### Files
- [x] **U-110 · P2 · all · The grid has a fixed 3 columns.** *(fixed in 2470e99)*
- The last row stretches its cells, so the columns don't line up. Tablet cells are huge
(`q_files_grid`).
- Where: `FilesScreen.tsx:989,1606`.
- [x] **U-111 · P2 · all · Dates and times ignore the app's language and the 12/24 h setting.** *(fixed in 63eefd7)*
- Example: "10:47 PM" next to 24 h times everywhere else.
- Where: `FilesScreen.tsx:127-132`.
- [x] **U-112 · P2 · all · Removing someone's share access has no confirmation.** *(fixed in d4982f6)* Where:
`ShareSheet.tsx:213`.
- [x] **U-113 · P2 · TL TP · There is no side-by-side preview.** *(fixed in 2470e99)* Fix: phase 2.
### Settings
- [x] **U-120 · P2 · all · Deleting a tag has no confirmation, while the reset next to it does.** *(fixed in 275d674)*
Where: `KeywordSettings.tsx:48,122`.
- [x] **U-121 · P2 · PP · The vacation subject input is a fixed 240 dp.** *(fixed in f22e851)*
- It squeezes its label into five lines.
- Where: `VacationSettings.tsx:434`; the same pattern in `AccountSecuritySettings.tsx:234`.
- [x] **U-122 · P2 · light · The amber banner in filters and the sieve editor is unreadable on
light.** *(fixed in 8f68d24)*
- Light yellow text on a faint brown fill.
- Where: `FilterSettings.tsx:515-519`, `SieveEditorSheet.tsx:208-212`.
- [x] **U-123 · P2 · TL TP · The section list and the page replace each other.** *(fixed in 8ab82cd)* Fix: phase 2.
- [ ] **U-124 · P3 · all · The language list is in neither name nor code order.** *(deferred: the order copies the webmail language switcher on purpose (comment in src/i18n/index.ts); change both together)* Example: Česky,
Slovenčina, Dansk.
### Login
- [x] **U-130 · P2 · TL TP · Login steps stretch to the full width.** *(fixed in 250c0a9)*
- Fields and buttons are 1248 dp wide (`t_login_confirm`).
- Fix: a centred column of about 480 dp.
- [x] **U-131 · P2 · all · Small targets: show-password 20 dp, header back 32 dp, email suggestion
chips about 26 dp.** *(fixed in ae1ef5b,c8434b0,776447d)* *(partly fixed in ae1ef5b (show password), c8434b0 (header); email suggestion chips still small)*
- [x] **U-132 · P2 · all · The password step's return keys don't move on.** *(fixed in ae1ef5b)*
- Username has no `next`. With TOTP on, the password field shows Next and does nothing.
- Where: `PasswordStep.tsx:56,78`.
- [ ] **U-133 · P2 · all · There is no way out while "Signing in" hangs.** *(deferred: a cancel needs an abortable sign-in in the auth flow, which is more than a UI fix; see product questions)* Hardware back is
swallowed. Where: `SigningInStep.tsx`, `LoginScreen.tsx:165`.
### Found in the re-walk
- [x] **U-134 · P3 · all · The all-day label in the day and week grid reads "All".** A one-row
strip cut the second line of "All day" ("Ganztägig" in German). *(fixed in e452f72)*
- [x] **U-135 · P2 · PP, split · The selection header pushed Delete off the screen.** Eight 44 dp
buttons with 12 dp gaps don't fit 412 dp; the count also kept its screen-reader label on the
normal header afterwards. *(fixed in 0d96d8d)*
- [ ] **U-136 · P3 · all · The day view keeps an empty all-day strip on days without all-day
events.** The strip takes the height of the busiest day in the loaded range so the grid doesn't
jump while paging (comment in `TimeGridScrollView.tsx`). *(deferred: a product call between a
steady grid and less blank space; see product questions)*
## Adaptive layout plan
**Breakpoints** come from the window width (`useWindowDimensions`), not the device type, so
split-screen, freeform windows and foldables work too:
| Class | Width | Typical |
|---|---|---|
| compact | < 600 dp | phones in portrait, narrow split-screen |
| medium | 600–839 dp | tablet portrait, unfolded foldables |
| expanded | ≥ 840 dp | tablet landscape |
A second flag, `shortHeight` (window height < 480 dp), covers phone landscape (914x411 dp). By
width alone that would be "expanded", but two panes and a rail in 411 dp of height are worse than
one pane. So a short window keeps the compact layouts and only compresses chrome: icon-only bars,
a merged header row, no tab labels.
A small hook, `src/lib/use-layout.ts`, returns `{ widthClass, shortHeight, isSplit }` and a pure
`layoutFor(width, height)` that can be tested.
**Navigation.** The phone stack stays as it is. A split view is a layout inside a tab, not a second
navigator:
- Medium and expanded put the tab bar on the left as a rail (`tabBarPosition: 'left'`, material
variant), like the webmail's navigation rail.
- Stack routes still exist and are still used on compact widths. On medium and expanded, the list
screens render the detail in a right-hand pane instead of pushing, and the detail screens' bodies
are shared components that take props instead of route params.
- If the window narrows while a pane shows something, the pane's selection is pushed as the normal
stack route, so nothing is lost.
**Per area:**
| Area | compact | medium | expanded |
|---|---|---|---|
| Mail | list → push thread | list (360 dp) + thread pane | persistent folder sidebar (280 dp) + list + thread |
| Calendar | as today | wider grid, 3-day shows 5 days | week shows 7 wide columns, 3-day shows 7, day shows 2 |
| Contacts | list → push detail | list + detail pane | list + detail pane |
| Files | list / grid | grid columns from width (about 140 dp each) | grid columns from width + preview pane when a file is selected |
| Settings | list ↔ page | section list (320 dp) + page | section list + page |
| Compose | full screen | centred panel, max 720 dp, dimmed backdrop | same |
| Sheets, dialogs, toasts | full width at the bottom | max 560 dp, centred | max 560 dp, centred |
| Login | as today | centred column, max 480 dp | same |
The thread, contact detail and settings pages cap their reading width (about 760 dp for the
mail body, 640 dp for forms) inside wide panes.
**State across rotation.** The activity keeps its state through `configChanges` (orientation,
screen size, smallest screen size, layout). Panes keep their selection in component state or the
store. Lists that depend on width remount with their focused index or date carried over.
## Final report (2026-10-15)
### Result
86 findings (U-001 to U-136; numbers were skipped where a candidate turned out to be a duplicate).
| Severity | Fixed | No change needed | Deferred |
| --- | --- | --- | --- |
| P1 | 9 | 0 | 0 |
| P2 | 57 | 1 | 2 |
| P3 | 7 | 4 | 6 |
Every logic change has a vitest test that fails without it. The full suite (3273 tests), `tsc`
and `npm run i18n:check` pass at the end.
### Areas and cells after the last walk
PP = phone portrait, PL = phone landscape, TP = tablet portrait, TL = tablet landscape. The last
walk ran the phone in German and dark, the tablet in English and light. Font scale 1.3 was checked
on PL and TL after a restart.
| Area | PP | PL | TP | TL |
| --- | --- | --- | --- | --- |
| Mail list, drawer, selection | ok | ok (search in header) | ok (list + message) | ok (folders + list + message) |
| Thread | ok | ok (icon-only toolbar) | ok (docked) | ok (docked) |
| Compose | ok | ok (format bar hidden while typing) | ok (panel) | ok (panel) |
| Calendar month / week / day | ok | ok | ok | ok |
| Contacts list, detail, form | ok | ok | ok (split) | ok (split) |
| Files and preview | ok | ok | ok (docked preview) | ok (docked preview) |
| Settings | ok | ok | ok (split) | ok (split) |
| Login | ok | ok | ok (centred column) | ok (centred column) |
### Deferred
- U-016 scrim token: all scrims are already the same; a refactor with no visible change.
- U-017 system alert vs Dialog: a style mismatch only; see the questions below.
- U-018 font size change while the app is open: handling `fontScale` in the app was tried and left
mixed old and new text sizes, worse than Android's recreate.
- U-045 "Zurück"/"Weiter": the strings come from the webmail's German catalog.
- U-069 recipient suggestions in phone landscape: no room above the keyboard; needs a new picker.
- U-124 language order: copies the webmail switcher on purpose.
- U-133 no way out of a hung sign-in: needs an abortable sign-in in the auth flow.
- U-136 empty all-day strip in the day view: a product call; see below.
### Product questions
1. Dark-mode links stay blue (`#60a5fa`) while the rest follows the webmail's neutral palette.
Recommendation: keep blue; links in mail bodies need to stand out.
2. Should every confirmation use the app's Dialog instead of the system alert (U-017)?
Recommendation: yes, for destructive actions first, as one pass of its own.
3. Sign-in that hangs (U-133): add a Cancel that aborts discovery and the token request.
Recommendation: yes; it is the only screen with no way back.
4. Day view all-day strip (U-136): keep one height across days, or size it to the day on screen?
Recommendation: size it to the visible days once paging settles.
5. Month view on a phone now shows titles instead of start times even with "show time" on, since
the time filled the whole chip. Recommendation: keep; times come back where cells are wide.
6. Files keeps a full-screen preview on phones and docks it on tablets. Recommendation: keep.
7. The dark background of plain-text mail is fixed at `#0a0a0a` and ignores the built-in themes.
Recommendation: take it from the theme palette.
8. APP.md still lists the dark palette as open; the app now uses the webmail standard theme. The
note should be updated in the branding repo.
### Not tested
- iOS and iPad: nothing was run. The risk is in the `supportedOrientations` change on every Modal,
the keyboard tracker (built around Android's missing frame after rotation) and the safe-area
insets in landscape.
- Real phones and tablets, foldables (fold and unfold mid-task), split-screen on real hardware.
- Push notifications and their deep links.
- Home-screen widgets: they truncate German labels; not changed here.
- The test account came from a login created for this audit ("Stalwart test – uxaudit@example.org"
in the Agent collection), because the "Stalwart test servers" item was not visible to the agent.
+110 -4
View File
@@ -128,12 +128,14 @@
"add_placeholder": "Aufgabe hinzufügen…",
"add_time": "Uhrzeit hinzufügen",
"clear_due_date": "Fälligkeitsdatum entfernen",
"delete_confirm": "Diese Aufgabe löschen?",
"delete_error": "Aufgabe konnte nicht gelöscht werden",
"not_found": "Diese Aufgabe ist nicht mehr verfügbar.",
"save_error": "Aufgabe konnte nicht gespeichert werden",
"update_error": "Aufgabe konnte nicht aktualisiert werden"
},
"views": {
"n_days": "{count} Tage",
"schedule": "Terminübersicht",
"three_days": "3 Tage"
}
@@ -310,6 +312,10 @@
"send_now": "Jetzt senden",
"send_timeout_body": "Die Nachricht wurde möglicherweise bereits versendet. Prüfen Sie Ihren Gesendet-Ordner, bevor Sie sie erneut senden.",
"send_timeout_title": "Keine Antwort vom Server",
"toolbar": {
"insert_image": "Bild einfügen",
"remove_link": "Link entfernen"
},
"undo_send_label": "Senden in {seconds} s…",
"upload_failed_short": "Upload fehlgeschlagen",
"uploading": "Wird hochgeladen...",
@@ -447,6 +453,7 @@
"share_no_matches": "Keine Treffer",
"share_no_users": "Keine anderen Benutzer gefunden",
"share_remove": "Zugriff entfernen",
"share_remove_confirm": "Zugriff entfernen?",
"share_role_custom": "Benutzerdefiniert",
"share_search_placeholder": "Nach Name oder E-Mail suchen",
"share_unavailable": "Teilen ist auf diesem Gerät nicht verfügbar",
@@ -641,12 +648,14 @@
},
"toolbar_labels": {
"description_mobile": "Textbeschriftungen neben den Symbolleistensymbolen anzeigen."
}
},
"description_mobile": "Passe an, wie die App aussieht"
},
"badges": {
"experimental": "Experimentell",
"not_implemented": "Nicht implementiert",
"unavailable": "Nicht verfügbar"
"unavailable": "Nicht verfügbar",
"not_on_server": "Nicht auf diesem Server"
},
"contacts": {
"book_count": "{count, plural, one {# Kontakt} other {# Kontakte}}",
@@ -866,6 +875,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",
@@ -988,7 +998,15 @@
"window": "Zeitraum",
"window_24h": "Letzte 24 Stunden",
"window_days": "Letzte {count} Tage",
"window_desc": "Wie weit zurück zwischengespeichert wird, gemessen am Empfangsdatum der Nachricht."
"window_desc": "Wie weit zurück zwischengespeichert wird, gemessen am Empfangsdatum der Nachricht.",
"description_short": "Neue Nachrichten auf dem Telefon behalten, damit sie auch ohne Verbindung aufgehen.",
"keep_24h": "24 Stunden",
"keep_days": "{count} Tage",
"keep_from": "Mails der letzten",
"messages_offline": "{count, plural, one {# Nachricht offline} other {# Nachrichten offline}}",
"synced": "Synchronisiert {when}",
"use_at_most": "Höchstens belegen",
"used_of": "{used} von {limit} MB"
},
"plugins": {
"mobile_description": "Plugins laufen im Webmail und erweitern es im Browser.",
@@ -1096,7 +1114,10 @@
"tabs": {
"plugins": "Plugins",
"themes": "Designs",
"updates": "Updates"
"updates": "Updates",
"storage": "Speicher & Sicherung",
"about": "Über",
"troubleshooting": "Fehlerbehebung"
},
"templates": {
"delete_confirm": "\"{name}\" endgültig löschen?",
@@ -1120,6 +1141,86 @@
"warnings": {
"date_format": "Datumsangaben müssen das Format \"YYYY-MM-DD\" oder \"YYYY-MM-DD HH:MM\" haben"
}
},
"about": {
"check_now": "Jetzt prüfen",
"checked_at": "Geprüft {when}",
"licences": "Open-Source-Lizenzen",
"licences_desc": "AGPL-3.0 für die App und die Bibliotheken, die sie nutzt",
"never_checked": "Noch nicht geprüft",
"privacy": "Datenschutz",
"privacy_desc": "Was auf diesem Telefon bleibt und was an deinen Server geht",
"report_problem": "Problem melden",
"report_problem_desc": "Öffnet ein Issue auf GitHub mit eingetragener Version",
"server": "Server",
"server_value": "{host} · angemeldet als {user}",
"source_code": "Quellcode",
"this_device": "Dieses Gerät",
"this_device_desc": "Womit die App verbunden ist und wo du Hilfe bekommst.",
"up_to_date": "Aktuell",
"updates": "Updates"
},
"backup": {
"description": "Übertrage deine Einstellungen auf ein anderes Telefon oder ins Webmail.",
"export_import": "Exportieren oder importieren",
"export_import_desc": "Eine JSON-Datei im selben Format wie im Webmail",
"reset": "Zurücksetzen",
"reset_desc": "Zurück auf die Standardwerte; Mails bleiben",
"title": "Einstellungen sichern"
},
"licences": {
"app_desc": "Die App ist freie Software unter der {licence}. Du darfst sie nutzen, untersuchen, weitergeben und ändern.",
"libraries": "Bibliotheken",
"libraries_desc": "Open-Source-Code, mit dem die App gebaut ist.",
"read_licence": "Den vollständigen Lizenztext lesen"
},
"privacy": {
"favicons": "Absendersymbole für geschäftliche Mails kommen vom Symboldienst von DuckDuckGo, der dabei die Domain des Absenders sieht. Ausschalten kannst du sie unter Darstellung → Absender-Favicons.",
"no_tracking": "Die App hat keine Analyse und keine Werbung.",
"other_title": "Weitere Verbindungen",
"phone_backup": "Nichts davon landet in der Cloud-Sicherung von Android.",
"phone_secure": "Deine Anmeldedaten liegen im sicheren Speicher des Systems. Einstellungen, der Offline-Cache und noch nicht synchronisierte Änderungen liegen im Speicher der App.",
"phone_title": "Auf diesem Telefon",
"push": "Wenn Benachrichtigungen an sind, meldet dein Server dem Push-Relay ({relay}), dass sich etwas geändert hat, und das Relay weckt die App über {service}. Das Relay sieht ein Gerätetoken und eine Änderungsmarke, nie deine Mails. Unter Benachrichtigungen kannst du ein eigenes Relay eintragen.",
"push_title": "Push-Benachrichtigungen",
"server": "Mails, Kalender, Kontakte und Dateien werden von deinem Server geladen und dort gespeichert. Außer den Verbindungen unten spricht die App nur mit deinem Server.",
"server_title": "Dein Mailserver",
"service_apple": "den Push-Dienst von Apple",
"service_google": "den Push-Dienst von Google",
"updates": "Die App fragt bei GitHub nach der neuesten Version, um dir Updates anzuzeigen.",
"updates_android": "Die App fragt bei GitHub nach der neuesten Version, um dir Updates anzuzeigen. Ausschalten kannst du das unter Updates."
},
"summary": {
"events_off": "Termine aus",
"events_on": "Termine an",
"mail_off": "Mail aus",
"mail_on": "Mail an",
"offline_off": "Offline-Mails aus",
"offline_on": "Offline-Mails an · {days, plural, one {# Tag} other {# Tage}}, {mb} MB",
"version": "Version {version}",
"week_starts": "Woche beginnt {day}",
"layout": "Schnellaktionen, Wischen, Reihenfolge",
"reading": "Gelesen-Status, Löschen, Anhänge",
"composing": "Signatur, Sendeverzögerung, Entwürfe",
"identities": "Adressen, von denen du sendest",
"vacation": "Automatische Antwort bei Abwesenheit",
"filters": "Regeln für eingehende Mails",
"templates": "Wiederverwendbare Nachrichten",
"folders": "Anlegen, umbenennen, Rollen",
"keywords": "Namen und Farben der Tags",
"downloads": "Dateinamen für gespeicherte Mails",
"security": "Passwort, Zwei-Faktor, App-Passwörter",
"content_senders": "Externe Bilder, vertrauenswürdige Absender",
"contacts": "Adressbücher, Sortierung",
"files": "Ansicht, Sortierung, Vorschaubilder",
"sidebar_apps": "Web-Apps im Seitenmenü",
"updates": "Neue Versionen installieren"
},
"troubleshooting": {
"cache_desc": "Wenn eine Liste veraltet oder falsch aussieht.",
"cache_title": "Zwischengespeicherte Daten",
"row_desc": "Daten neu laden, Debug-Protokoll",
"title": "Fehlerbehebung"
}
},
"sidebar": {
@@ -1128,6 +1229,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": {
+106 -2
View File
@@ -131,12 +131,14 @@
"add_placeholder": "Add a task…",
"add_time": "Add time",
"clear_due_date": "Clear due date",
"delete_confirm": "Delete this task?",
"delete_error": "Failed to delete task",
"not_found": "This task is no longer available.",
"save_error": "Failed to save task",
"update_error": "Failed to update task"
},
"views": {
"n_days": "{count} days",
"schedule": "Schedule",
"three_days": "3 days"
}
@@ -321,7 +323,9 @@
"send_timeout_title": "No answer from the server",
"toolbar": {
"background_color": "Background color",
"remove_background_color": "Remove background color"
"insert_image": "Insert image",
"remove_background_color": "Remove background color",
"remove_link": "Remove link"
},
"undo_send_label": "Sending in {seconds}s…",
"upload_failed_short": "Upload failed",
@@ -460,6 +464,7 @@
"share_no_matches": "No matches",
"share_no_users": "No other users found",
"share_remove": "Remove access",
"share_remove_confirm": "Remove access?",
"share_role_custom": "Custom",
"share_search_placeholder": "Search by name or email",
"share_unavailable": "Sharing is not available on this device",
@@ -622,6 +627,24 @@
"send_now_failed": "Could not send now"
},
"settings": {
"about": {
"check_now": "Check now",
"checked_at": "Checked {when}",
"licences": "Open-source licences",
"licences_desc": "AGPL-3.0 for the app, and the libraries it uses",
"never_checked": "Not checked yet",
"privacy": "Privacy",
"privacy_desc": "What stays on this phone and what goes to your server",
"report_problem": "Report a problem",
"report_problem_desc": "Opens an issue on GitHub with the version filled in",
"server": "Server",
"server_value": "{host} · signed in as {user}",
"source_code": "Source code",
"this_device": "This device",
"this_device_desc": "What the app is connected to and how to get help.",
"up_to_date": "Up to date",
"updates": "Updates"
},
"account": {
"accounts": {
"description_mobile": "Reorder how accounts appear in the account switcher, pick the default, or sign one out.",
@@ -648,6 +671,7 @@
}
},
"appearance": {
"description_mobile": "Customize how the app looks",
"language": {
"restart_for_direction": "Restart the app to apply the new text direction.",
"system_default": "System default"
@@ -656,9 +680,18 @@
"description_mobile": "Display text labels next to toolbar icons."
}
},
"backup": {
"description": "Move your settings to another phone or the webmail.",
"export_import": "Export or import",
"export_import_desc": "A JSON file, same format as the webmail",
"reset": "Reset",
"reset_desc": "Back to the defaults; mail stays",
"title": "Settings backup"
},
"badges": {
"experimental": "Experimental",
"not_implemented": "Not implemented",
"not_on_server": "Not on this server",
"unavailable": "Unavailable"
},
"contacts": {
@@ -885,6 +918,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",
@@ -922,6 +956,12 @@
"unified_cross_account": "Unified views across accounts",
"unified_cross_account_description": "Combine every signed-in account in \"All inboxes\". Off keeps the unified views inside the active account and its shared folders."
},
"licences": {
"app_desc": "The app is free software under the {licence}. You can use, study, share and change it.",
"libraries": "Libraries",
"libraries_desc": "Open-source code the app is built with.",
"read_licence": "Read the full licence text"
},
"message_list_order": {
"description": "Which messages come first in the list. Newest first is the tie-breaker within each group; the header toggle still flips oldest/newest.",
"preset_chronological": "Newest first",
@@ -987,23 +1027,31 @@
"clear_title": "Clear offline mail",
"days_ago": "{count} d ago",
"description": "Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand.",
"description_short": "Keep recent messages on this phone so they open without a connection.",
"downloading": "Downloading {completed}/{total}…",
"enabled": "Cache recent mail",
"enabled_desc": "When on, the app keeps message bodies for the selected window on this device.",
"hours_ago": "{count} h ago",
"just_now": "just now",
"keep_24h": "24 hours",
"keep_days": "{count} days",
"keep_from": "Keep mail from the last",
"last_sync": "Last sync {when}",
"last_sync_failed": "Last sync failed: {message}",
"max_size": "Maximum size",
"max_size_desc": "Oldest messages are removed when the cache grows past this.",
"messages_offline": "{count, plural, one {# message offline} other {# messages offline}}",
"minutes_ago": "{count} min ago",
"never": "never",
"nothing_cached": "Nothing cached yet",
"queued_changes": "{count, plural, one {# change} other {# changes}} waiting to sync",
"scanning": "Scanning recent mail…",
"sync_now": "Sync now",
"synced": "Synced {when}",
"syncing": "Syncing…",
"title": "Offline mail",
"use_at_most": "Use at most",
"used_of": "{used} of {limit} MB",
"window": "Window",
"window_24h": "Last 24 hours",
"window_days": "Last {count} days",
@@ -1013,6 +1061,22 @@
"mobile_description": "Plugins run inside the webmail and extend it in the browser.",
"mobile_explainer": "The mobile app has no plugin runtime. Install and manage plugins from the webmail under Settings → Plugins; themes and filters they provide are not applied here."
},
"privacy": {
"favicons": "Sender icons for business mail come from DuckDuckGo's icon service, which sees the sender's domain. Turn them off under Appearance → Sender favicons.",
"no_tracking": "The app has no analytics and no ads.",
"other_title": "Other connections",
"phone_backup": "None of it goes into Android's cloud backup.",
"phone_secure": "Your sign-in details are kept in the system's secure storage. Settings, the offline mail cache and changes waiting to sync are kept in the app's own storage.",
"phone_title": "On this phone",
"push": "When notifications are on, your server tells the push relay ({relay}) that something changed, and the relay wakes the app through {service}. The relay sees a device token and a change marker, never your mail. You can use your own relay under Notifications.",
"push_title": "Push notifications",
"server": "Mail, calendars, contacts and files are loaded from your server and saved back to it. Apart from the connections below, the app talks only to your server.",
"server_title": "Your mail server",
"service_apple": "Apple's push service",
"service_google": "Google's push service",
"updates": "The app asks GitHub for the latest release to tell you about updates.",
"updates_android": "The app asks GitHub for the latest release to tell you about updates. Turn that off under Updates."
},
"security": {
"client_cert": {
"active": "Active: {alias}",
@@ -1112,10 +1176,39 @@
"mobile_description": "Sign and encrypt mail with S/MIME certificates.",
"mobile_explainer": "S/MIME is not available in the mobile app yet. Manage keys and certificates in the webmail; signed messages are shown without verification here."
},
"summary": {
"events_off": "events off",
"events_on": "events on",
"mail_off": "Mail off",
"mail_on": "Mail on",
"offline_off": "Offline mail off",
"offline_on": "Offline mail on · {days, plural, one {# day} other {# days}}, {mb} MB",
"version": "Version {version}",
"week_starts": "week starts {day}",
"layout": "Quick actions, swipes, list order",
"reading": "Read status, delete, attachments",
"composing": "Signature, send delay, drafts",
"identities": "Addresses you send from",
"vacation": "Automatic reply while away",
"filters": "Rules for incoming mail",
"templates": "Reusable messages",
"folders": "Create, rename, roles",
"keywords": "Tag names and colours",
"downloads": "File names for saved mail",
"security": "Password, two-factor, app passwords",
"content_senders": "Remote images, trusted senders",
"contacts": "Address books, sorting",
"files": "View, sorting, thumbnails",
"sidebar_apps": "Web apps in the side menu",
"updates": "Install new versions"
},
"tabs": {
"plugins": "Plugins",
"themes": "Themes",
"updates": "Updates"
"updates": "Updates",
"storage": "Storage & backup",
"about": "About",
"troubleshooting": "Troubleshooting"
},
"templates": {
"delete_confirm": "Permanently delete \"{name}\"?",
@@ -1135,6 +1228,12 @@
"mobile_description": "Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail.",
"title": "Themes"
},
"troubleshooting": {
"cache_desc": "When a list looks stale or wrong.",
"cache_title": "Cached data",
"row_desc": "Refresh cached data, debug logging",
"title": "Troubleshooting"
},
"vacation": {
"warnings": {
"date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\""
@@ -1147,6 +1246,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": {
-15
View File
@@ -2,19 +2,4 @@ const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
// babel.config.js rewrites lucide-react-native imports to per-icon modules
// under dist/ (scripts/babel-plugin-lucide-imports.js). The package's
// `exports` map only lists its barrel, so resolve those paths as plain files
// instead of letting Metro warn about every one of them.
const LUCIDE_DEEP_IMPORT = 'lucide-react-native/dist/';
const upstreamResolveRequest = config.resolver.resolveRequest;
config.resolver.resolveRequest = (context, moduleName, platform) => {
const resolve = upstreamResolveRequest ?? context.resolveRequest;
if (moduleName.startsWith(LUCIDE_DEEP_IMPORT)) {
return resolve({ ...context, unstable_enablePackageExports: false }, moduleName, platform);
}
return resolve(context, moduleName, platform);
};
module.exports = config;
+3 -14
View File
@@ -1,12 +1,13 @@
{
"name": "bulwark-mobile",
"version": "0.1.75",
"version": "0.1.78",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.75",
"version": "0.1.78",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -33,7 +34,6 @@
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"jszip": "^3.10.1",
"lucide-react-native": "^1.7.0",
"postal-mime": "^3.0.0",
"react": "19.1.0",
"react-native": "0.81.5",
@@ -7790,17 +7790,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react-native": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.7.0.tgz",
"integrity": "sha512-wGJY5nosSawh028jg8r1ZKqnGPDIVfIL9xvKOs4wPYFQHeJMHsADYm/lmuFYXMXXatSkHhpsCjeqIRgeFGzf8g==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-native": "*",
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.75",
"version": "0.1.78",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -43,7 +43,6 @@
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"jszip": "^3.10.1",
"lucide-react-native": "^1.7.0",
"postal-mime": "^3.0.0",
"react": "19.1.0",
"react-native": "0.81.5",
-91
View File
@@ -1,91 +0,0 @@
// Babel plugin: rewrite named imports from the `lucide-react-native` barrel to
// the per-icon modules they re-export, so the bundle only contains (and the
// app only evaluates) the icons it uses. The barrel re-exports all ~1,700
// icons, and Metro has no tree shaking, so one `import { Mail }` used to pull
// in and run every icon at startup.
//
// import { Mail, Trash2 as Bin } from 'lucide-react-native';
// becomes
// import Mail from 'lucide-react-native/dist/esm/icons/mail.js';
// import Bin from 'lucide-react-native/dist/esm/icons/trash-2.js';
//
// The name -> file table is read from the package's own ESM barrel, so icon
// aliases (`MailIcon`, `LucideMail`, renamed icons) resolve exactly as they do
// through the barrel. metro.config.js resolves these deep paths, which the
// package's `exports` map does not list.
const fs = require('node:fs');
const path = require('node:path');
const PACKAGE = 'lucide-react-native';
const ESM_DIR = 'dist/esm';
/** Map every name the barrel exports to `{ source, imported }`. */
function loadExportMap() {
// `main` is dist/cjs/lucide-react-native.js; the ESM tree sits next to it.
const pkgDir = path.resolve(path.dirname(require.resolve(PACKAGE)), '..', '..');
const barrel = fs.readFileSync(path.join(pkgDir, ESM_DIR, `${PACKAGE}.js`), 'utf8');
const map = new Map();
for (const m of barrel.matchAll(/export\s*\{([^}]*)\}\s*from\s*'\.\/([^']+)'/g)) {
const source = `${PACKAGE}/${ESM_DIR}/${m[2]}`;
for (const spec of m[1].split(',')) {
const [imported, exported = imported] = spec.trim().split(/\s+as\s+/);
if (imported) map.set(exported, { source, imported });
}
}
if (map.size === 0) {
throw new Error(`${PACKAGE}: could not read the export list from its ESM barrel`);
}
return map;
}
let exportMap = null;
function getExportMap() {
if (!exportMap) exportMap = loadExportMap();
return exportMap;
}
// A value-less binding (only used as a type) is dropped by the TypeScript
// transform anyway; treat it the same way instead of failing the build.
function isOnlyUsedAsType(importPath, localName) {
const binding = importPath.scope.getBinding(localName);
if (!binding) return true;
return binding.referencePaths.every((ref) => Boolean(ref.findParent((p) => p.isTSType())));
}
function lucideImportsPlugin({ types: t }) {
return {
name: 'lucide-direct-imports',
visitor: {
ImportDeclaration(importPath) {
const { node } = importPath;
if (node.source.value !== PACKAGE || node.importKind === 'type') return;
// Side-effect, default and namespace imports keep using the barrel.
if (node.specifiers.length === 0) return;
if (!node.specifiers.every((s) => t.isImportSpecifier(s))) return;
const map = getExportMap();
const replacements = [];
for (const spec of node.specifiers) {
if (spec.importKind === 'type') continue;
const name = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
const target = map.get(name);
if (!target) {
if (isOnlyUsedAsType(importPath, spec.local.name)) continue;
throw importPath.buildCodeFrameError(`${PACKAGE} has no export named "${name}"`);
}
const local = t.identifier(spec.local.name);
const specifier = target.imported === 'default'
? t.importDefaultSpecifier(local)
: t.importSpecifier(local, t.identifier(target.imported));
replacements.push(t.importDeclaration([specifier], t.stringLiteral(target.source)));
}
if (replacements.length === 0) importPath.remove();
else importPath.replaceWithMultiple(replacements);
},
},
};
}
module.exports = lucideImportsPlugin;
module.exports.loadExportMap = loadExportMap;
module.exports.PACKAGE = PACKAGE;
+241
View File
@@ -0,0 +1,241 @@
// Writes src/components/icons/index.ts from Tabler's outline path data.
//
// The app draws the same icon set as the webmail (Tabler Icons, MIT) under the
// names it used when it was on Lucide, so screens import `{ Mail, Trash2 }`
// from components/icons. Only the glyphs listed in MAP ship in the bundle.
//
// node scripts/generate-icons.mjs [path/to/tabler-nodes-outline.json]
//
// The default path is the webmail checkout this repo usually sits in
// (repos/react-native inside jmap-webmail), whose node_modules has
// @tabler/icons. Add a line to MAP to make a new icon importable.
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SOURCE = process.argv[2]
?? join(ROOT, '..', '..', 'node_modules', '@tabler', 'icons', 'tabler-nodes-outline.json');
// App name -> Tabler outline name. Where the webmail (components/icons) maps
// the same name, this uses its choice.
const MAP = {
AlarmClock: 'alarm',
AlertCircle: 'alert-circle',
AlertTriangle: 'alert-triangle',
AlignCenter: 'align-center',
AlignLeft: 'align-left',
AlignRight: 'align-right',
Archive: 'archive',
ArrowDown: 'arrow-down',
ArrowDownWideNarrow: 'sort-descending',
ArrowLeft: 'arrow-left',
ArrowRight: 'arrow-right',
ArrowUp: 'arrow-up',
ArrowUpDown: 'arrows-sort',
ArrowUpNarrowWide: 'sort-ascending',
Ban: 'ban',
Bell: 'bell',
BellRing: 'bell-ringing',
Bold: 'bold',
Book: 'book-2',
BookOpen: 'book',
BookUser: 'address-book',
Bug: 'bug',
Building: 'building',
Cake: 'cake',
Calendar: 'calendar',
CalendarDays: 'calendar-month',
CalendarPlus: 'calendar-plus',
Camera: 'camera',
Check: 'check',
CheckCheck: 'checks',
CheckCircle: 'circle-check',
CheckCircle2: 'circle-check',
CheckSquare: 'square-check',
ChevronDown: 'chevron-down',
ChevronLeft: 'chevron-left',
ChevronRight: 'chevron-right',
ChevronUp: 'chevron-up',
CircleCheck: 'circle-check',
ClipboardPaste: 'clipboard-text',
Clock: 'clock',
CloudDownload: 'cloud-download',
CloudOff: 'cloud-off',
Code: 'code',
Columns3: 'columns-3',
Columns4: 'layout-columns',
Copy: 'copy',
Database: 'database',
Download: 'download',
Edit2: 'pencil',
Eraser: 'eraser',
ExternalLink: 'external-link',
Eye: 'eye',
EyeOff: 'eye-off',
File: 'file',
FileArchive: 'file-zip',
FileAudio: 'file-music',
FileCode: 'file-code',
FileCode2: 'file-code-2',
FileImage: 'photo',
FileInput: 'file-import',
FileSpreadsheet: 'file-spreadsheet',
FileText: 'file-text',
FileVideo: 'video',
Filter: 'filter',
Flag: 'flag',
FlaskConical: 'flask',
Folder: 'folder',
FolderArchive: 'archive',
FolderInput: 'folder-down',
FolderOpen: 'folder-open',
FolderPlus: 'folder-plus',
FolderSync: 'folders',
Forward: 'arrow-forward-up',
Globe: 'world',
Grid3x3: 'grid-dots',
GripVertical: 'grip-vertical',
HardDrive: 'server-2',
Heading1: 'h-1',
Heading2: 'h-2',
Heart: 'heart',
HelpCircle: 'help-circle',
Highlighter: 'highlight',
Image: 'photo',
Import: 'file-import',
Inbox: 'inbox',
Info: 'info-circle',
Italic: 'italic',
Key: 'key',
KeyRound: 'key',
Languages: 'language',
Layers: 'stack',
LayoutGrid: 'layout-grid',
LayoutTemplate: 'template',
Link2: 'link',
Link2Off: 'unlink',
List: 'list',
ListChecks: 'list-check',
ListOrdered: 'list-numbers',
Lock: 'lock',
LogOut: 'logout',
Mail: 'mail',
MailCheck: 'mail-check',
MailMinus: 'mail-minus',
MailOpen: 'mail-opened',
Mails: 'inbox',
MapPin: 'map-pin',
Maximize2: 'maximize',
Menu: 'menu-2',
MessageSquare: 'message',
Minus: 'minus',
Monitor: 'device-desktop',
Moon: 'moon',
MoreHorizontal: 'dots',
MoreVertical: 'dots-vertical',
MoveVertical: 'arrows-vertical',
Palette: 'palette',
Palmtree: 'beach',
PanelLeftClose: 'layout-sidebar-left-collapse',
PanelRight: 'layout-sidebar-right',
Paperclip: 'paperclip',
PenLine: 'pencil',
PenSquare: 'edit',
Pencil: 'pencil',
Phone: 'phone',
Pin: 'pinned',
PinOff: 'pinned-off',
Plus: 'plus',
Puzzle: 'puzzle',
QrCode: 'qrcode',
Quote: 'quote',
Redo2: 'arrow-forward',
RefreshCw: 'refresh',
RemoveFormatting: 'clear-formatting',
Repeat: 'repeat',
Reply: 'arrow-back-up',
ReplyAll: 'arrow-back-up-double',
RotateCcw: 'rotate',
Rows2: 'layout-rows',
Rss: 'rss',
Save: 'device-floppy',
ScanSearch: 'zoom-scan',
Scale: 'scale',
Search: 'search',
Send: 'send',
Server: 'server',
Settings: 'settings',
Share2: 'share-2',
Shield: 'shield',
ShieldAlert: 'shield-exclamation',
ShieldCheck: 'shield-check',
ShieldQuestion: 'shield-question',
Shuffle: 'arrows-shuffle',
Smartphone: 'device-mobile',
Square: 'square',
SquareCheck: 'square-check',
SquarePen: 'edit',
Star: 'star',
StickyNote: 'note',
Strikethrough: 'strikethrough',
Sun: 'sun',
Table: 'table',
Tag: 'tag',
Tags: 'tags',
Terminal: 'terminal',
Trash: 'trash',
Trash2: 'trash',
Type: 'typography',
Underline: 'underline',
Undo2: 'arrow-back',
Upload: 'upload',
User: 'user',
UserCircle: 'user-circle',
UserMinus: 'user-minus',
UserPen: 'user-edit',
UserPlus: 'user-plus',
UserRound: 'user',
UserRoundPlus: 'user-plus',
Users: 'users',
Video: 'video',
X: 'x',
XCircle: 'circle-x',
};
function main() {
const nodes = JSON.parse(readFileSync(SOURCE, 'utf8'));
const glyphs = new Map();
const missing = [];
for (const [appName, tabler] of Object.entries(MAP)) {
const icon = nodes[tabler];
if (!icon) { missing.push(`${appName} -> ${tabler}`); continue; }
const paths = icon.map(([tag, attrs]) => {
if (tag !== 'path') throw new Error(`${tabler}: unexpected <${tag}>`);
return attrs.d;
});
glyphs.set(tabler, paths);
}
if (missing.length) throw new Error(`Not in Tabler: ${missing.join(', ')}`);
const lines = [
'// Generated by scripts/generate-icons.mjs from @tabler/icons (MIT).',
'// Do not edit by hand: add the icon to MAP in the script and run it.',
'',
"import { makeIcon } from './icon';",
'',
"export type { AppIcon, IconProps } from './icon';",
'',
'const G = {',
...[...glyphs.keys()].sort().map((k) => ` '${k}': ${JSON.stringify(glyphs.get(k))},`),
'} as const;',
'',
...Object.entries(MAP).map(([a, t]) => `export const ${a} = makeIcon('${a}', G['${t}']);`),
'',
];
writeFileSync(join(ROOT, 'src', 'components', 'icons', 'index.ts'), lines.join('\n'));
console.log(`${Object.keys(MAP).length} names, ${glyphs.size} glyphs`);
}
main();
+46
View File
@@ -0,0 +1,46 @@
// Writes src/lib/licences.json: the libraries the app ships (the runtime
// dependencies in package.json) with their licence, for Settings → About →
// Open-source licences. Run after adding or upgrading a dependency:
//
// node scripts/generate-licences.mjs
//
// The test in src/lib/__tests__/licences.test.ts fails when a dependency is
// missing from the list.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
// Code that isn't an npm dependency but ships in the app.
const VENDORED = [
{ name: 'Tabler Icons', version: '', license: 'MIT', url: 'https://tabler.io/icons' },
];
function repoUrl(meta) {
const repo = typeof meta.repository === 'string' ? meta.repository : meta.repository?.url;
const url = meta.homepage || repo || '';
return url
.replace(/^git\+/, '')
.replace(/^git:\/\//, 'https://')
.replace(/^github:/, 'https://github.com/')
.replace(/\.git$/, '')
.replace(/#.*$/, '');
}
const libraries = Object.keys(pkg.dependencies).sort().map((name) => {
const file = join(ROOT, 'node_modules', name, 'package.json');
if (!existsSync(file)) throw new Error(`${name} is not installed`);
const meta = JSON.parse(readFileSync(file, 'utf8'));
const license = typeof meta.license === 'string' ? meta.license : meta.license?.type ?? 'See package';
return { name, version: meta.version, license, url: repoUrl(meta) };
});
const out = {
app: { name: pkg.name, license: pkg.license },
libraries: [...libraries, ...VENDORED],
};
writeFileSync(join(ROOT, 'src', 'lib', 'licences.json'), `${JSON.stringify(out, null, 2)}\n`);
console.log(`${out.libraries.length} libraries`);
+17 -11
View File
@@ -1,20 +1,25 @@
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { radius, typography, type ThemePalette } from '../theme/tokens';
import { radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface BadgeProps {
/** Sentence case ("Not on this server"); badges are never upper case. */
children: string;
variant?: 'default' | 'primary' | 'destructive' | 'outline' | 'muted';
style?: ViewStyle;
}
/**
* A small status pill. The default is the webmail's outline badge: hairline
* border, muted 12px text. The tinted variants keep the same size.
*/
export default function Badge({ children, variant = 'default', style }: BadgeProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.base, styles[`bg_${variant}` as const], style]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]} numberOfLines={1}>
{children}
</Text>
</View>
@@ -25,23 +30,24 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'transparent',
paddingHorizontal: 8,
paddingVertical: 2,
paddingVertical: 1,
alignSelf: 'flex-start',
},
text: { ...typography.caption, fontWeight: '500' },
text: { fontSize: 12, lineHeight: 16 },
bg_default: { backgroundColor: c.surface },
bg_primary: { backgroundColor: c.primaryBg },
bg_default: { borderColor: c.border },
bg_outline: { borderColor: c.border },
bg_primary: { backgroundColor: c.accent },
bg_destructive: { backgroundColor: c.errorBg },
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
bg_muted: { backgroundColor: c.muted },
text_default: { color: c.text },
text_primary: { color: c.primary },
text_default: { color: c.mutedForeground },
text_outline: { color: c.mutedForeground },
text_primary: { color: c.accentForeground },
text_destructive: { color: c.error },
text_outline: { color: c.text },
text_muted: { color: c.mutedForeground },
});
}
+51 -25
View File
@@ -1,9 +1,19 @@
import React from 'react';
import { Pressable, Text, StyleSheet, ViewStyle, ActivityIndicator } from 'react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { spacing, radius, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type ButtonVariant = 'default' | 'ghost' | 'outline' | 'destructive';
/**
* The webmail's standard-theme buttons (components/ui/button.tsx):
* - default: bg-primary text-primary-foreground
* - secondary: bg-muted text-foreground
* - outline: border, transparent
* - ghost: no border or fill until pressed
* - destructive: red fill, white text
* - destructiveOutline: red border and text, for a reset in a settings row
* All 36 dp tall (lg 44 for a page's main action), radius 6, 14px medium.
*/
type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'outline' | 'destructive' | 'destructiveOutline';
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
interface ButtonProps {
@@ -15,8 +25,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 every size reaches a 48 dp target.
const HIT_SLOP: Record<ButtonSize, number> = { sm: 6, md: 6, lg: 2, icon: 4 };
export default function Button({
variant = 'default',
size = 'md',
@@ -26,10 +41,14 @@ export default function Button({
loading = false,
icon,
style,
accessibilityLabel,
}: ButtonProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isDisabled = disabled || loading;
const spinnerColor = variant === 'default'
? c.primaryForeground
: variant === 'destructive' ? '#ffffff' : c.text;
return (
<Pressable
@@ -43,17 +62,18 @@ export default function Button({
]}
onPress={onPress}
disabled={isDisabled}
hitSlop={HIT_SLOP[size]}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ disabled: isDisabled, busy: loading }}
>
{loading ? (
<ActivityIndicator
size="small"
color={variant === 'default' ? c.primaryForeground : c.primary}
/>
<ActivityIndicator size="small" color={spinnerColor} />
) : (
<>
{icon}
{typeof children === 'string' ? (
<Text style={[styles.text, styles[`text_${variant}` as const], isDisabled && styles.textDisabled]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]} numberOfLines={1}>
{children}
</Text>
) : (
@@ -69,30 +89,36 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
borderRadius: radius.md, gap: spacing.sm,
borderRadius: radius.sm, gap: spacing.sm,
},
text: { ...typography.bodyMedium },
text: { fontSize: 14, fontWeight: '500', lineHeight: 20 },
disabled: { opacity: 0.5 },
textDisabled: { opacity: 0.5 },
size_sm: { height: componentSizes.buttonSm, paddingHorizontal: spacing.md },
size_md: { height: componentSizes.buttonMd, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm },
size_lg: { height: componentSizes.buttonLg, paddingHorizontal: spacing.xxxl },
// minHeight, so a larger system font grows the button instead of clipping.
size_sm: { minHeight: componentSizes.buttonSm, paddingHorizontal: spacing.md },
size_md: { minHeight: componentSizes.buttonSm, paddingHorizontal: 14 },
size_lg: { minHeight: componentSizes.buttonLg, paddingHorizontal: spacing.xxl },
size_icon: { height: componentSizes.buttonMd, width: componentSizes.buttonMd, paddingHorizontal: 0 },
bg_default: { backgroundColor: c.primary, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
bg_ghost: { backgroundColor: 'transparent' },
bg_outline: { backgroundColor: c.background, borderWidth: 1, borderColor: c.border },
bg_destructive: { backgroundColor: c.error, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
bg_default: { backgroundColor: c.primary },
bg_secondary: { backgroundColor: c.muted },
bg_ghost: { backgroundColor: 'transparent' },
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
bg_destructive: { backgroundColor: c.error },
bg_destructiveOutline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.error },
pressed_default: { backgroundColor: c.primaryDark, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
pressed_ghost: { backgroundColor: c.accent },
pressed_outline: { backgroundColor: c.accent },
pressed_destructive: { opacity: 0.9 },
pressed_default: { opacity: 0.9 },
pressed_secondary: { opacity: 0.8 },
pressed_ghost: { backgroundColor: c.muted },
pressed_outline: { backgroundColor: c.muted },
pressed_destructive: { opacity: 0.9 },
pressed_destructiveOutline: { backgroundColor: c.errorBg },
text_default: { color: c.primaryForeground },
text_ghost: { color: c.text },
text_outline: { color: c.text },
text_destructive: { color: c.errorForeground },
text_default: { color: c.primaryForeground },
text_secondary: { color: c.text },
text_ghost: { color: c.text },
text_outline: { color: c.text },
text_destructive: { color: '#ffffff' },
text_destructiveOutline: { color: c.error },
});
}
+8 -5
View File
@@ -1,10 +1,11 @@
import React from 'react';
import { View, Text, StyleSheet, Modal, TextInput, type TextInputProps } from 'react-native';
import { AlertTriangle } from 'lucide-react-native';
import { AlertTriangle } from './icons';
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}>
@@ -71,7 +72,7 @@ export default function Dialog({
value={input.value}
onChangeText={input.onChangeText}
placeholder={input.placeholder}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
autoFocus
onSubmitEditing={confirmDisabled ? undefined : onConfirm}
accessibilityLabel={input.placeholder ?? title}
@@ -142,15 +143,17 @@ function makeStyles(c: ThemePalette) {
color: c.mutedForeground, // text-muted-foreground
marginTop: spacing.sm,
},
// The shared Input look: 40 dp, transparent, radius 6.
input: {
...typography.body,
color: c.text,
backgroundColor: c.surface,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: 10,
paddingVertical: 0,
minHeight: 40,
marginTop: spacing.md,
},
footer: {
+1 -1
View File
@@ -3,7 +3,7 @@ import { View, StyleSheet, Linking, Platform, Text, Pressable, useWindowDimensio
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
import { Image as ImageIcon, ShieldCheck } from './icons';
import type { Email } from '../api/types';
import { extractCidRefs } from '../lib/email-html';
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
+8 -4
View File
@@ -3,12 +3,14 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Check, Mail } from 'lucide-react-native';
import { X, Check, Mail } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
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')}
>
+17 -14
View File
@@ -9,16 +9,17 @@ 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>;
}
/**
* Matches webmail input.tsx:
* - h-10 w-full rounded-md border border-input bg-background
* - px-3 py-2 text-sm text-foreground
* - placeholder:text-muted-foreground
* - hover:border-muted-foreground
* - focus:ring-2 ring-ring border-ring
* - disabled:opacity-50
* Matches webmail input.tsx in the standard theme:
* - h-10 rounded-md border border-input, transparent background
* - px-3 text-sm text-foreground, placeholder in muted-foreground
* - focus: border-ring
* - disabled: opacity-50
* - label above in text-sm font-medium text-foreground
*/
export default function Input({
label,
@@ -27,6 +28,7 @@ export default function Input({
leftIcon,
rightIcon,
style,
ref,
...props
}: InputProps) {
const c = useColors();
@@ -44,9 +46,10 @@ export default function Input({
]}>
{leftIcon && <View style={styles.iconLeft}>{leftIcon}</View>}
<RNTextInput
ref={ref}
{...props}
style={[styles.input, style]}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
onFocus={(e) => { setFocused(true); props.onFocus?.(e); }}
onBlur={(e) => { setFocused(false); props.onBlur?.(e); }}
/>
@@ -61,22 +64,22 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
label: {
...typography.bodyMedium,
color: c.textSecondary,
marginBottom: spacing.sm,
color: c.text,
marginBottom: 6,
},
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
backgroundColor: c.background, // bg-background
backgroundColor: 'transparent',
paddingHorizontal: spacing.md, // px-3 = 12px
},
// Colour only, so focusing doesn't shift the text by a pixel.
inputFocused: {
borderColor: c.borderFocus, // border-ring (blue-500)
borderWidth: 2,
borderColor: c.borderFocus,
},
inputError: {
borderColor: c.error,
+10 -6
View File
@@ -5,8 +5,8 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Folder,
X, Check, type LucideIcon,
} from 'lucide-react-native';
X, Check, type AppIcon,
} from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
@@ -14,8 +14,10 @@ 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 {
function moveTargetIcon(role: string | null | undefined, name: string): AppIcon {
const lower = name.toLowerCase();
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
if (role === 'sent' || lower.includes('sent')) return Send;
@@ -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')}
>
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CloudOff } from 'lucide-react-native';
import { CloudOff } from './icons';
import { useNetworkStore } from '../stores/network-store';
import { useOutboxStore } from '../stores/outbox-store';
import { useLocaleStore } from '../stores/locale-store';
+1 -1
View File
@@ -4,7 +4,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { CloudDownload, X } from 'lucide-react-native';
import { CloudDownload, X } from './icons';
import { useOfflineCacheStore } from '../stores/offline-cache-store';
import { useUpdatesStore } from '../stores/updates-store';
import { formatBytes } from '../lib/offline-sync';
+3 -2
View File
@@ -12,13 +12,14 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import * as Clipboard from 'expo-clipboard';
import { ClipboardPaste, X } from 'lucide-react-native';
import { ClipboardPaste, X } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
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}>
+6 -7
View File
@@ -1,7 +1,8 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BellRing, X } from 'lucide-react-native';
import { useFloatingCardFrame } from '../lib/use-layout';
import { BellRing, X } from './icons';
import Button from './Button';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -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: {
+3 -2
View File
@@ -2,11 +2,12 @@ import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from 'lucide-react-native';
import { X } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
import Button from './Button';
import { useLocaleStore } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface QrScanModalProps {
visible: boolean;
@@ -42,7 +43,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<View style={styles.container}>
{permission?.granted ? (
<CameraView
+5
View File
@@ -65,6 +65,8 @@ export interface RichTextEditorHandle {
*/
getHtml(timeoutMs?: number): Promise<string>;
focus(): void;
/** Drop the caret, which closes the keyboard. */
blur(): void;
}
interface Props {
@@ -176,6 +178,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
focus: () => {
call(`window.__rne && window.__rne.focus()`);
},
blur: () => {
call(`document.activeElement && document.activeElement.blur()`);
},
}), [call]);
const onMessage = (event: WebViewMessageEvent) => {
+447 -393
View File
@@ -10,8 +10,8 @@ import {
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
Clock, Layers, Users, Tag, Mails, MailOpen, StickyNote, AlarmClock, Flag,
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus,
type LucideIcon,
} from 'lucide-react-native';
type AppIcon,
} from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useAnimDuration } from '../theme/dynamic';
@@ -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;
@@ -62,7 +65,7 @@ function iconFor(
name: string | undefined,
hasChildren: boolean,
isExpanded: boolean,
): LucideIcon {
): AppIcon {
switch (role) {
case 'inbox': return Inbox;
case 'sent': return Send;
@@ -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')}
@@ -219,7 +224,7 @@ function SidebarRow({
interface SheetAction {
key: string;
label: string;
icon: LucideIcon;
icon: AppIcon;
destructive?: boolean;
onPress: () => void;
}
@@ -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);
@@ -678,7 +685,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
};
}, [mailboxes, accountSnapshots, activeAccountId, unifiedCrossAccount, includeGroupInUnified]);
const unifiedIcon = (role: UnifiedRole): LucideIcon => iconFor(role, undefined, false, false);
const unifiedIcon = (role: UnifiedRole): AppIcon => iconFor(role, undefined, false, false);
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
@@ -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',
@@ -1404,10 +1459,9 @@ function makeStyles(c: ThemePalette) {
color: c.text,
},
rowLabelAccount: {
...typography.caption,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
fontSize: 13,
lineHeight: 18,
fontWeight: '500',
color: c.textSecondary,
},
counts: {
+4 -4
View File
@@ -4,8 +4,8 @@ import {
} from 'react-native';
import {
Archive, Trash2, ShieldAlert, ShieldCheck, MailOpen, Mail, Star, Pin, PinOff, FolderInput,
type LucideIcon,
} from 'lucide-react-native';
type AppIcon,
} from './icons';
import { typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useLocaleStore } from '../stores/locale-store';
@@ -43,7 +43,7 @@ export interface SwipeContext {
// Distance the row flies off-screen by before the action callback fires.
const EXIT_DISTANCE = 600;
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: AppIcon; bg: string; defaultLabel: string }> = {
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
delete: { icon: Trash2, bg: '#b91c1c', defaultLabel: 'Delete' },
spam: { icon: ShieldAlert, bg: '#a16207', defaultLabel: 'Spam' },
@@ -68,7 +68,7 @@ function actionLabel(action: SwipeAction, context: SwipeContext, t: Translate):
}
}
function actionIcon(action: Exclude<SwipeAction, 'none'>, context: SwipeContext): LucideIcon {
function actionIcon(action: Exclude<SwipeAction, 'none'>, context: SwipeContext): AppIcon {
if (action === 'spam' && context.inJunk) return ShieldCheck;
if (action === 'read' && !context.unread) return Mail;
if (action === 'pin' && context.pinned) return PinOff;
+11 -7
View File
@@ -3,14 +3,16 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} 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 { X, Check, Tag } from './icons';
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} />}
+8 -4
View File
@@ -3,13 +3,15 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Star, FileText } from 'lucide-react-native';
import { X, Star, FileText } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
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')}
>
+8 -4
View File
@@ -4,7 +4,8 @@ import {
type AccessibilityActionEvent, type LayoutChangeEvent,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AlertCircle, AlertTriangle, CheckCircle2, Info } from 'lucide-react-native';
import { useFloatingCardFrame } from '../lib/use-layout';
import { AlertCircle, AlertTriangle, CheckCircle2, Info } from './icons';
import { spacing, radius, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
import { useShouldAnimate } from '../theme/dynamic';
@@ -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,
},
+13 -7
View File
@@ -12,11 +12,9 @@ interface ToggleSwitchProps {
}
/**
* Matches webmail ToggleSwitch:
* - h-6 w-11 rounded-full
* - checked: bg-primary, unchecked: bg-muted
* - thumb: h-4 w-4 rounded-full bg-background
* - translate-x-6 (checked) / translate-x-1 (unchecked)
* The webmail switch in the standard theme: a 44 x 24 track, bg-primary when
* on (knob in primary-foreground), bg-muted when off (knob in background).
* The knob is 20 dp with 2 dp of track around it.
*/
export default function ToggleSwitch({ value, onValueChange, disabled = false, accessibilityLabel }: ToggleSwitchProps) {
const c = useColors();
@@ -29,6 +27,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 }}
@@ -36,7 +36,8 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false, a
<View
style={[
styles.thumb,
{ transform: [{ translateX: value ? 24 : 4 }] },
value ? styles.thumbOn : styles.thumbOff,
{ transform: [{ translateX: value ? 22 : 2 }] },
]}
/>
</Pressable>
@@ -58,9 +59,14 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.muted,
},
thumb: {
width: componentSizes.toggleThumb, // h-4 w-4 = 16
width: componentSizes.toggleThumb,
height: componentSizes.toggleThumb,
borderRadius: radius.full,
},
thumbOn: {
backgroundColor: c.primaryForeground,
},
thumbOff: {
backgroundColor: c.background,
},
disabled: {
+4 -5
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFloatingCardFrame } from '../lib/use-layout';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -11,8 +12,6 @@ import { useLocaleStore } from '../stores/locale-store';
import type { RootStackParamList } from '../navigation/types';
const VISIBLE_MS = 5000;
// Clears the tab bar and the viewer's action bar, like the toasts (ToastHost).
const BOTTOM_CLEARANCE = 64;
interface Shown {
label: string;
@@ -32,6 +31,8 @@ export function UndoSnackbar() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
// Full width above the tab bar on a phone; bottom right on a tablet.
const frame = useFloatingCardFrame();
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const entry = useEmailStore((s) => s.pendingUndo);
@@ -128,7 +129,7 @@ export function UndoSnackbar() {
pointerEvents="box-none"
style={[
styles.wrap,
{ bottom: insets.bottom + BOTTOM_CLEARANCE, opacity, transform: [{ translateY: slideY }] },
{ left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom, opacity, transform: [{ translateY: slideY }] },
]}
>
<View style={styles.bar}>
@@ -160,8 +161,6 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
zIndex: 50,
},
bar: {
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Linking } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Download, ShieldAlert, X } from 'lucide-react-native';
import { Download, ShieldAlert, X } from './icons';
import { useUpdatesStore } from '../stores/updates-store';
import { useLocaleStore } from '../stores/locale-store';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -8,7 +8,7 @@ import { findUntranslatedLiterals } from './untranslated-literals';
// Pane file (relative to src/components/) → texts that stay as they are on
// purpose: product names, units, format patterns and sample data.
const TRANSLATED_PANES: Record<string, string[]> = {
'settings/AboutDataSettings.tsx': ['MB', 'GitHub'],
'settings/AboutSettings.tsx': [],
'settings/AccountSecuritySettings.tsx': ['AES-128', 'AES-256'],
'settings/AccountSettings.tsx': [],
'settings/AppearanceSettings.tsx': [],
@@ -31,12 +31,14 @@ const TRANSLATED_PANES: Record<string, string[]> = {
'settings/LanguageSettings.tsx': [],
'settings/LayoutSettings.tsx': [],
'settings/NotificationSettings.tsx': [],
'settings/PluginsSettings.tsx': [],
'settings/LicencesSettings.tsx': ['GNU Affero General Public License v3.0'],
'settings/PrivacySettings.tsx': [],
'settings/ReadingSettings.tsx': [],
'settings/SidebarAppsSettings.tsx': ['https://example.com'],
'settings/SmimeSettings.tsx': [],
'settings/StorageSettings.tsx': ['MB'],
'settings/TemplateSettings.tsx': ['JSON', '{"templates": [...]}'],
'settings/ThemesSettings.tsx': [],
'settings/TroubleshootingSettings.tsx': [],
'settings/UpdatesSettings.tsx': [],
'settings/VacationSettings.tsx': ['YYYY-MM-DD HH:MM'],
'settings/settings-section.tsx': [],
@@ -10,13 +10,15 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Check } from 'lucide-react-native';
import { Check } from '../icons';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
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}>
+6 -2
View File
@@ -1,6 +1,7 @@
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 { CalendarPlus, CircleCheck, Plus } from '../icons';
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAnimDuration } from '../../theme/dynamic';
@@ -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>
@@ -11,12 +11,14 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Trash2, UserPlus, Users } from 'lucide-react-native';
import { Trash2, UserPlus, Users } from '../icons';
import { getPrincipals, ownPrincipalId } from '../../api/files';
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}>
@@ -13,8 +13,8 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import {
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, RefreshCw,
type LucideIcon,
} from 'lucide-react-native';
type AppIcon,
} from '../icons';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -23,12 +23,13 @@ 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 {
key: string;
label: string;
Icon: LucideIcon;
Icon: AppIcon;
}
interface CalendarSidebarDrawerProps {
@@ -151,7 +152,7 @@ export function CalendarSidebarDrawer({
};
return (
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Clock, MapPin, Users } from 'lucide-react-native';
import { Clock, MapPin, Users } from '../icons';
import { format } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
+17 -10
View File
@@ -35,7 +35,7 @@ import {
Users,
Video,
X,
} from 'lucide-react-native';
} from '../icons';
import { format, type Locale } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -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,
+28 -23
View File
@@ -7,8 +7,8 @@ import {
TextInput,
Pressable,
StyleSheet,
Switch,
Platform,
Alert as NativeAlert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import DateTimePicker from '@react-native-community/datetimepicker';
@@ -24,7 +24,7 @@ import {
Users,
Trash2,
AlignLeft,
} from 'lucide-react-native';
} from '../icons';
import { addHours, addMinutes, format } from 'date-fns';
import type {
Alert,
@@ -46,6 +46,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 +68,8 @@ 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';
import ToggleSwitch from '../ToggleSwitch';
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"
@@ -450,7 +459,7 @@ export function EventModal({
value={title}
onChangeText={setTitle}
placeholder={t('calendar.form.title', 'Title')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
style={styles.titleInput}
/>
@@ -506,12 +515,10 @@ export function EventModal({
<Section icon={<Clock size={16} color={c.textMuted} />} label={t('calendar.form.time', 'Time')}>
<View style={styles.allDayRow}>
<Text style={styles.fieldText}>{t('calendar.form.all_day_event', 'All-day event')}</Text>
<Switch
<ToggleSwitch
value={allDay}
onValueChange={setAllDay}
accessibilityLabel={t('calendar.form.all_day_event', 'All-day event')}
thumbColor={allDay ? c.primary : c.textMuted}
trackColor={{ false: c.surface, true: c.primaryBg }}
/>
</View>
@@ -522,7 +529,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 +549,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
@@ -664,7 +671,7 @@ export function EventModal({
keyboardType="number-pad"
style={styles.customAmountInput}
placeholder="30"
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
/>
<View style={styles.customUnits}>
{(['minutes', 'hours', 'days', 'weeks'] as ReminderUnit[]).map((unit) => (
@@ -699,7 +706,7 @@ export function EventModal({
value={location}
onChangeText={setLocation}
placeholder={t('calendar.form.location_placeholder', 'Place or address')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
style={styles.fieldButton}
/>
</Section>
@@ -709,7 +716,7 @@ export function EventModal({
value={videoUrl}
onChangeText={setVideoUrl}
placeholder="https://meet.example.com/…"
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
@@ -725,7 +732,7 @@ export function EventModal({
value={description}
onChangeText={setDescription}
placeholder={t('calendar.form.description_placeholder', 'Notes, agenda…')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
multiline
style={styles.descriptionInput}
/>
@@ -747,12 +754,10 @@ export function EventModal({
<Text style={[styles.fieldText, { flex: 1 }]}>
{t('calendar.participants.send_invitations', 'Send invitations to participants')}
</Text>
<Switch
<ToggleSwitch
value={sendInvitations}
onValueChange={setSendInvitations}
accessibilityLabel={t('calendar.participants.send_invitations', 'Send invitations to participants')}
thumbColor={sendInvitations ? c.primary : c.textMuted}
trackColor={{ false: c.surface, true: c.primaryBg }}
/>
</View>
)}
@@ -904,7 +909,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
justifyContent: 'center',
},
fieldText: { ...typography.body, color: c.text },
@@ -921,7 +926,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
},
reminderRemove: { padding: 2 },
customReminderRow: {
@@ -930,7 +935,7 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
},
customAmountInput: {
...typography.body,
@@ -966,7 +971,7 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
backgroundColor: c.popover,
overflow: 'hidden',
},
popoverRow: {
@@ -988,7 +993,7 @@ function makeStyles(c: ThemePalette) {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
borderWidth: 1,
borderColor: c.border,
},
@@ -1000,7 +1005,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
},
dateLabel: { ...typography.body, color: c.textSecondary, flex: 1 },
datePickers: { flexDirection: 'row', gap: spacing.sm },
@@ -1021,7 +1026,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
color: c.text,
...typography.body,
textAlignVertical: 'top',
+7 -3
View File
@@ -3,7 +3,7 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from 'lucide-react-native';
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from '../icons';
import { format, parseISO } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { uploadBlob } from '../../api/blob';
@@ -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,9 +1,9 @@
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';
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from '../icons';
import { formatDistanceToNow } from 'date-fns';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -17,6 +17,8 @@ import {
type CalendarSubscription,
} from '../../stores/calendar-subscriptions-store';
import { jmapClient } from '../../api/jmap-client';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
// The webmail's refresh intervals and their labels.
const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [
@@ -75,6 +77,8 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
};
const slideY = React.useRef(new Animated.Value(900)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
@@ -131,11 +135,11 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Rss size={20} color={c.text} />
@@ -275,7 +279,24 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
)}
</Pressable>
<Pressable
onPress={() => { void removeSubscription(sub.id); }}
// Unsubscribing also deletes the calendar and its events.
onPress={() => Alert.alert(
t('calendar.subscription.unsubscribe', 'Unsubscribe'),
t('calendar.subscription.confirm_delete', 'Unsubscribe from "{name}"? The calendar and all its events will be removed.', { name: sub.name }),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{
text: t('calendar.subscription.unsubscribe', 'Unsubscribe'),
style: 'destructive',
onPress: () => {
removeSubscription(sub.id).catch((err: unknown) => Alert.alert(
t('calendar.subscription.delete_error', 'Failed to remove subscription'),
err instanceof Error ? err.message : undefined,
));
},
},
],
)}
hitSlop={6}
style={styles.subBtn}
accessibilityRole="button"
+26 -7
View File
@@ -1,6 +1,7 @@
import React from 'react';
import {
FlatList,
ScrollView,
StyleSheet,
View,
useWindowDimensions,
@@ -49,7 +50,13 @@ export function MonthDropdown({
}: MonthDropdownProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { width } = useWindowDimensions();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
// Pages are as wide as the panel (the window, or a pane on a tablet) and
// re-measure on rotation. In a short window the panel takes at most about
// half of it and its month scrolls inside.
const [measuredWidth, setMeasuredWidth] = React.useState(0);
const width = measuredWidth || windowWidth;
const panelHeight = Math.min(PANEL_HEIGHT, Math.round(windowHeight * 0.55));
// The months are fixed while the panel is open; it remounts on each open.
const [months] = React.useState(() => {
const first = addMonths(startOfMonth(month), -MONTHS_EACH_SIDE);
@@ -69,7 +76,11 @@ export function MonthDropdown({
const renderItem = React.useCallback(
({ item }: ListRenderItemInfo<Date>) => (
<View style={{ width, height: PANEL_HEIGHT }}>
<ScrollView
style={{ width, height: panelHeight }}
scrollEnabled={panelHeight < PANEL_HEIGHT}
showsVerticalScrollIndicator={false}
>
<MonthView
currentDate={item}
selectedDate={selectedDate}
@@ -79,9 +90,9 @@ export function MonthDropdown({
weekStartsOn={weekStartsOn}
onSelectDate={onSelectDate}
/>
</View>
</ScrollView>
),
[width, selectedDate, eventsByDay, calendars, weekStartsOn, onSelectDate],
[width, panelHeight, selectedDate, eventsByDay, calendars, weekStartsOn, onSelectDate],
);
const getItemLayout = React.useCallback(
@@ -90,20 +101,28 @@ export function MonthDropdown({
);
return (
<View style={styles.panel}>
<View
style={styles.panel}
onLayout={(e) => {
const w = Math.round(e.nativeEvent.layout.width);
if (w > 0 && w !== measuredWidth) setMeasuredWidth(w);
}}
>
<FlatList
// A new width remounts the pager on the month it was showing.
key={width}
horizontal
pagingEnabled
data={months}
keyExtractor={monthKey}
renderItem={renderItem}
getItemLayout={getItemLayout}
initialScrollIndex={MONTHS_EACH_SIDE}
initialScrollIndex={lastIndexRef.current}
onMomentumScrollEnd={handleMomentumEnd}
showsHorizontalScrollIndicator={false}
initialNumToRender={1}
windowSize={3}
style={{ height: PANEL_HEIGHT }}
style={{ height: panelHeight }}
/>
</View>
);
+33 -9
View File
@@ -18,6 +18,7 @@ import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window';
import {
dayIndexIn,
monthFocusRow,
monthRowGeometry,
monthKeyOf,
monthMask,
sampledRow,
@@ -124,8 +125,9 @@ function MonthScrollViewInner({
const height = e.nativeEvent.layout.height;
if (height > 0) setListHeight(height);
}, []);
const rowHeight = Math.max(MONTH_ROW_HEIGHT_FULL_MIN, Math.floor(listHeight / VISIBLE_ROWS));
const viewportHeight = rowHeight * VISIBLE_ROWS;
// A phone in landscape has room for three or four weeks, not six.
const { rowHeight, visibleRows } = monthRowGeometry(listHeight, MONTH_ROW_HEIGHT_FULL_MIN, VISIBLE_ROWS);
const viewportHeight = listHeight;
// The month in focus: set by navigation, then by what scrolls under the
// sample line.
@@ -134,16 +136,28 @@ function MonthScrollViewInner({
const onVisibleRef = React.useRef(onVisibleDateChange);
onVisibleRef.current = onVisibleDateChange;
const offsetRef = React.useRef(0);
// Row at the top of the viewport; a change of row height (the "show time"
// setting) remounts the list there.
const topRowRef = React.useRef<number | null>(null);
// Week at the top of the viewport; a change of row height (the "show time"
// setting, a rotation) remounts the list there. Kept as the week, not its
// index, because weeks added above shift the indices.
const topWeekRef = React.useRef<string | null>(null);
// A (re)mounted list reports offset 0 before its initial scroll lands; that
// report would set the month to the window's first one (a rotation jumped
// back a month). Until the list reaches its start row, or the user drags,
// scroll reports don't move the month.
const settledRef = React.useRef(false);
const handleScroll = React.useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const offset = e.nativeEvent.contentOffset.y;
if (!settledRef.current) {
const expected = (initialRef.current?.row ?? 0) * rowHeight;
if (Math.abs(offset - expected) > rowHeight / 2) return;
settledRef.current = true;
}
offsetRef.current = offset;
topRowRef.current = Math.round(offset / rowHeight);
const list = weeksRef.current;
const top = list[Math.max(0, Math.min(list.length - 1, Math.round(offset / rowHeight)))];
if (top) topWeekRef.current = dayKey(top[0]);
const row = sampledRow(offset, viewportHeight, rowHeight, list.length);
const days = list[row];
if (!days) return;
@@ -158,15 +172,18 @@ function MonthScrollViewInner({
[viewportHeight, rowHeight],
);
const markSettled = React.useCallback(() => { settledRef.current = true; }, []);
// Navigation inside the window scrolls the focused month's first week to
// the top. The list mounts there already, so its own nonce needs nothing.
const listRef = React.useRef<FlatList<Date[]>>(null);
const handledNonceRef = React.useRef(focus.nonce);
const initialRef = React.useRef<{ rowHeight: number; row: number } | null>(null);
if (initialRef.current === null || initialRef.current.rowHeight !== rowHeight) {
settledRef.current = false;
initialRef.current = {
rowHeight,
row: topRowRef.current ?? monthFocusRow(window, focus.date, weeks.length, opts),
row: rowOfWeek(weeks, topWeekRef.current) ?? monthFocusRow(window, focus.date, weeks.length, opts),
};
}
React.useEffect(() => {
@@ -263,12 +280,13 @@ function MonthScrollViewInner({
onEndReached={onExtendEnd}
onEndReachedThreshold={0.5}
onScroll={handleScroll}
onScrollBeginDrag={markSettled}
scrollEventThrottle={32}
snapToInterval={rowHeight}
decelerationRate="fast"
showsVerticalScrollIndicator={false}
initialNumToRender={VISIBLE_ROWS + 2}
maxToRenderPerBatch={VISIBLE_ROWS}
initialNumToRender={visibleRows + 2}
maxToRenderPerBatch={visibleRows}
windowSize={5}
/>
)}
@@ -282,3 +300,9 @@ export const MonthScrollView = React.memo(MonthScrollViewInner);
function weekKey(days: Date[]): string {
return dayKey(days[0]);
}
function rowOfWeek(weeks: Date[][], key: string | null): number | null {
if (!key) return null;
const i = weeks.findIndex((days) => dayKey(days[0]) === key);
return i >= 0 ? i : null;
}
+14 -5
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { View, Text, StyleSheet, Pressable, useWindowDimensions } from 'react-native';
import {
addDays,
endOfMonth,
@@ -24,7 +24,7 @@ import {
type EventDayIndex,
type TimeFormat,
} from '../../lib/calendar-utils';
import { dayIndexIn, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll';
import { dayIndexIn, monthChipShowsTime, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll';
import { isInactiveEvent } from '../../lib/calendar-participants';
import { eventBlockColors } from '../../lib/event-colors';
import { useCalendarLocale } from '../../lib/calendar-locale';
@@ -38,6 +38,8 @@ export const MONTH_ROW_HEIGHT = 54;
export const MONTH_ROW_HEIGHT_CHIPS = 74;
// Smallest week row of the full-screen month grid.
export const MONTH_ROW_HEIGHT_FULL_MIN = 72;
/** Smallest row of the paged month grid, which can't scroll to hidden weeks. */
const MONTH_ROW_HEIGHT_FILL_MIN = 36;
// Corner radius of event bars (repos/branding/APP.md).
const EVENT_RADIUS = 2;
@@ -151,6 +153,8 @@ function MonthWeekRowInner({
onLongPressDate,
}: MonthWeekRowProps) {
const c = useColors();
const { width } = useWindowDimensions();
const chipTime = monthChipShowsTime(true, width / 7);
if (variant === 'full') {
return (
<FullWeekRow
@@ -256,7 +260,7 @@ function MonthWeekRowInner({
]}
>
<Text style={[styles.chipText, { color: colors.text }]} numberOfLines={1}>
{event.showWithoutTime
{event.showWithoutTime || !chipTime
? null
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `}
<Text style={inactive && styles.chipTextInactive}>{event.title || ''}</Text>
@@ -316,6 +320,8 @@ function FullWeekRow({
height: number;
colors: ThemePalette;
}) {
const { width } = useWindowDimensions();
const showTime = monthChipShowsTime(showTimeInMonthView, width / 7);
return (
<View style={[styles.fullWeekRow, { height }]}>
{showWeekNumbers && (
@@ -355,7 +361,7 @@ function FullWeekRow({
const inactive = isInactiveEvent(event, currentUserEmails);
const colors = eventBlockColors(getEventColor(event, calendars), inactive, c);
// No time on events that fill the whole day.
const time = showTimeInMonthView && !event.showWithoutTime && !isTimedEventFullDayOnDate(event, d)
const time = showTime && !event.showWithoutTime && !isTimedEventFullDayOnDate(event, d)
? `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `
: '';
return (
@@ -424,8 +430,11 @@ function MonthViewInner({
// Today on a clock in the calendar's time zone.
const today = displayNow();
// Every week of the month has to show (there is no scrolling here), so in a
// short window (phone landscape) rows shrink below the usual minimum and
// their chips collapse into "+N".
const fullRowHeight = fill && gridHeight > 0
? Math.max(MONTH_ROW_HEIGHT_FULL_MIN, Math.floor(gridHeight / rows.length))
? Math.max(MONTH_ROW_HEIGHT_FILL_MIN, Math.floor(gridHeight / rows.length))
: undefined;
return (
+4 -1
View File
@@ -7,7 +7,7 @@ import {
TextInput,
ScrollView,
} from 'react-native';
import { X } from 'lucide-react-native';
import { X } from '../icons';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useContactsStore } from '../../stores/contacts-store';
@@ -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')}
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, TextInput, Pressable, StyleSheet, Platform } from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
import { ChevronDown } from 'lucide-react-native';
import { ChevronDown } from '../icons';
import { format, parseISO } from 'date-fns';
import type { RecurrenceRule } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -1,10 +1,11 @@
import React from 'react';
import { Modal, View, Text, Pressable, StyleSheet } from 'react-native';
import { Repeat, Trash2 } from 'lucide-react-native';
import { Repeat, Trash2 } from '../icons';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
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}>
+14 -9
View File
@@ -24,7 +24,7 @@ import {
Plus,
Trash2,
X,
} from 'lucide-react-native';
} from '../icons';
import { format, isBefore, startOfDay } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -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
@@ -525,7 +530,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
},
addBtn: {
width: 38,
@@ -547,7 +552,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
textAlignVertical: 'top',
},
editorRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
+28 -2
View File
@@ -61,6 +61,8 @@ type TimeFormat = '12h' | '24h';
interface TimeGridScrollViewProps {
mode: TimeGridMode;
/** Days of the multi-day mode (`multiDayCount`). */
multiDays?: number;
/** The day the user navigated to; its week (week view) or itself (day view) is scrolled into view. */
focus: CalendarFocus;
/** The loaded window: one column per day. */
@@ -96,6 +98,7 @@ interface TimeGridScrollViewProps {
*/
function TimeGridScrollViewInner({
mode,
multiDays = 3,
focus,
window,
selectedDate,
@@ -120,7 +123,7 @@ function TimeGridScrollViewInner({
[eventsByDay, events],
);
const perScreen = columnsPerScreen(mode);
const perScreen = columnsPerScreen(mode, multiDays);
const { width: screenWidth } = useWindowDimensions();
const [rootWidth, setRootWidth] = React.useState(screenWidth);
const listWidth = Math.max(perScreen, rootWidth - GUTTER_WIDTH);
@@ -217,6 +220,7 @@ function TimeGridScrollViewInner({
listener: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const list = daysRef.current;
offsetXRef.current = e.nativeEvent.contentOffset.x;
if (e.nativeEvent.contentOffset.x > 0) scrolledSinceMountRef.current = true;
const col = Math.max(
0,
Math.min(list.length - 1, Math.round(e.nativeEvent.contentOffset.x / colWidthRef.current)),
@@ -239,7 +243,12 @@ function TimeGridScrollViewInner({
const visibleHandlerRef = React.useRef(onVisibleDateChange);
visibleHandlerRef.current = onVisibleDateChange;
const lastVisibleKeyRef = React.useRef<string | null>(null);
// Right after a (re)mount the list reports its first days as visible
// before the initial scroll lands; that would set the title to the window's
// first month. Reports wait until the list has scrolled at all.
const scrolledSinceMountRef = React.useRef(false);
const onViewableItemsChanged = React.useRef(({ viewableItems }: { viewableItems: ViewToken[] }) => {
if (!scrolledSinceMountRef.current) return;
let first: Date | null = null;
for (const token of viewableItems) {
const day = token.item as Date;
@@ -379,6 +388,21 @@ function TimeGridScrollViewInner({
),
};
}
// The remounted list starts at that day, but the header strip still holds
// the old pixel offset until the list scrolls: move it now, or the header
// shows other days (or none) after a rotation.
React.useLayoutEffect(() => {
const index = initialIndexRef.current?.index ?? 0;
scrolledSinceMountRef.current = false;
offsetXRef.current = index * colWidth;
scrollX.setValue(offsetXRef.current);
const day = days[index];
if (day) {
headerAnchorRef.current = day;
setHeaderAnchor(day);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listKey]);
const listStyle = React.useMemo(() => ({ width: listWidth, height: GRID_HEIGHT }), [listWidth]);
return (
@@ -388,7 +412,9 @@ function TimeGridScrollViewInner({
<View style={{ height: HEADER_HEIGHT }} />
{allDayHeight > 0 && (
<View style={[styles.allDayLabelWrap, { height: allDayHeight }]}>
<Text style={styles.allDayLabel} numberOfLines={2}>
{/* One line that shrinks to the gutter: a second line was cut
off by a one-row strip, leaving "All". */}
<Text style={styles.allDayLabel} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.6}>
{t('calendar.events.all_day', 'All day')}
</Text>
</View>
+1 -1
View File
@@ -179,7 +179,7 @@ function WeekViewInner({
{allDayRowCount > 0 && (
<View style={[styles.allDayStrip, { height: allDayStripHeight }]}>
<View style={[styles.gutter, styles.allDayLabelWrap]}>
<Text style={styles.allDayLabel} numberOfLines={2}>{t('calendar.events.all_day', 'All day')}</Text>
<Text style={styles.allDayLabel} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.6}>{t('calendar.events.all_day', 'All day')}</Text>
</View>
<View style={styles.allDayGrid}>
{/* Empty per-day columns to draw vertical separators */}
@@ -3,12 +3,14 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, Alert,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BookUser, X, Check, Plus } from 'lucide-react-native';
import { BookUser, X, Check, Plus } from '../icons';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
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')}
+4 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Mail as MailIcon, CalendarDays } from 'lucide-react-native';
import { Mail as MailIcon, CalendarDays } from '../icons';
import { format, isAfter, parseISO, type Locale } from 'date-fns';
import type { ContactCard, Email, CalendarEvent } from '../../api/types';
import type { RootStackParamList } from '../../navigation/types';
@@ -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',
@@ -4,7 +4,7 @@ import {
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import * as FileSystem from 'expo-file-system/legacy';
import { Upload, X, Check, FileText, AlertTriangle, BookUser } from 'lucide-react-native';
import { Upload, X, Check, FileText, AlertTriangle, BookUser } from '../icons';
import type { ContactCard } from '../../api/types';
import { parseVCard, detectDuplicates } from '../../lib/vcard';
import { getContactDisplayName, getContactPrimaryEmail } from '../../lib/contact-utils';
@@ -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>
+13 -2
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Image } from 'react-native';
import { Check, Users } from 'lucide-react-native';
import { Check, Users } from '../icons';
import type { ContactCard } from '../../api/types';
import {
getContactDisplayName,
@@ -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,
@@ -125,6 +136,6 @@ function makeStyles(c: ThemePalette) {
info: { flex: 1, minWidth: 0 },
name: { ...typography.bodyMedium, color: c.text },
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 1 },
tertiary: { ...typography.small, color: c.textSecondary, marginTop: 1 },
tertiary: { ...typography.caption, color: c.mutedForeground, marginTop: 1 },
});
}
@@ -4,7 +4,7 @@ import {
Animated, Dimensions, Easing,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Search, X, Check } from 'lucide-react-native';
import { Search, X, Check } from '../icons';
import type { ContactCard } from '../../api/types';
import {
useContactsStore,
@@ -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}>
@@ -7,7 +7,7 @@ import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, Plus, Share2, Check,
} from 'lucide-react-native';
} from '../icons';
import type { RootStackParamList } from '../../navigation/types';
import type { AddressBook } from '../../api/types';
import type { ContactCategory } from '../../stores/contacts-store';
@@ -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>
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Plus, X } from 'lucide-react-native';
import { Plus, X } from '../icons';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
+8 -4
View File
@@ -3,12 +3,14 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Tag, X, Plus } from 'lucide-react-native';
import { Tag, X, Plus } from '../icons';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
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')}
+8 -4
View File
@@ -1,11 +1,13 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Modal, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X } from 'lucide-react-native';
import { X } from '../icons';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
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')}
>
+1 -1
View File
@@ -3,7 +3,7 @@ import { Alert } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import * as Clipboard from 'expo-clipboard';
import { Copy, PenSquare, UserRound, UserRoundPlus } from 'lucide-react-native';
import { Copy, PenSquare, UserRound, UserRoundPlus } from '../icons';
import type { EmailAddress } from '../../api/types';
import type { RootStackParamList } from '../../navigation/types';
import { useContactsStore } from '../../stores/contacts-store';
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive } from 'lucide-react-native';
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive } from '../icons';
import type { File } from 'expo-file-system';
import type { Attachment, Email } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -4,7 +4,7 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { WebView } from 'react-native-webview';
import { X, ExternalLink, Share2, Download } from 'lucide-react-native';
import { X, ExternalLink, Share2, Download } from '../icons';
import type { Email } from '../../api/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -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>
);
}
@@ -4,7 +4,7 @@ import { openExternalUrl } from '../../lib/open-url';
import {
CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle,
ShieldCheck, ShieldAlert, ChevronDown,
} from 'lucide-react-native';
} from '../icons';
import { format, parseISO } from 'date-fns';
import type { Calendar, Email, CalendarEvent } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { File as FileIcon, FileArchive, FileImage, FileSpreadsheet, FileText } from 'lucide-react-native';
import { File as FileIcon, FileArchive, FileImage, FileSpreadsheet, FileText } from '../icons';
import type { Attachment, Email, Mailbox } from '../../api/types';
import { getEmailAttachments } from '../../api/email';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
+1 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import {
Star, ChevronDown, ChevronUp, Reply, Forward, ShieldCheck, ShieldAlert, ShieldQuestion, Lock,
} from 'lucide-react-native';
} from '../icons';
import type { Email, EmailAddress, Identity } from '../../api/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
+13 -7
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, TextInput, Pressable, ActivityIndicator, Alert, Keyboard } from 'react-native';
import { Send, Maximize2 } from 'lucide-react-native';
import { Send, Maximize2 } from '../icons';
import type { Email } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -147,18 +147,21 @@ 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}
onBlur={onBlur}
placeholder={t('email_viewer.quick_reply_placeholder', 'Write a quick reply...')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
style={styles.input}
multiline
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>
@@ -183,7 +189,7 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.md,
backgroundColor: c.surface,
backgroundColor: c.background,
padding: spacing.sm,
gap: spacing.sm,
},
@@ -205,10 +211,10 @@ function makeStyles(c: ThemePalette) {
gap: spacing.xs,
backgroundColor: c.primary,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.full,
minHeight: 36,
borderRadius: radius.sm,
},
sendDisabled: { opacity: 0.5 },
sendText: { ...typography.caption, color: c.primaryForeground, fontWeight: '600' },
sendText: { ...typography.bodyMedium, color: c.primaryForeground },
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
import { MailCheck } from 'lucide-react-native';
import { MailCheck } from '../icons';
import type { Email } from '../../api/types';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
+12 -11
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Paperclip, Reply, ReplyAll, Forward, Star } from 'lucide-react-native';
import { Paperclip, Reply, ReplyAll, Forward, Star } from '../icons';
import type { Email } from '../../api/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -74,16 +74,16 @@ 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}>
<Reply size={16} color={c.textSecondary} />
<Pressable style={styles.actionBtn} onPress={reply('reply')} hitSlop={6}>
<Reply size={16} color={c.text} />
<Text style={styles.actionLabel}>{t('email_viewer.reply', 'Reply')}</Text>
</Pressable>
<Pressable style={styles.actionBtn} onPress={reply('replyAll')} hitSlop={4}>
<ReplyAll size={16} color={c.textSecondary} />
<Pressable style={styles.actionBtn} onPress={reply('replyAll')} hitSlop={6}>
<ReplyAll size={16} color={c.text} />
<Text style={styles.actionLabel}>{t('email_viewer.reply_all', 'Reply All')}</Text>
</Pressable>
<Pressable style={styles.actionBtn} onPress={reply('forward')} hitSlop={4}>
<Forward size={16} color={c.textSecondary} />
<Pressable style={styles.actionBtn} onPress={reply('forward')} hitSlop={6}>
<Forward size={16} color={c.text} />
<Text style={styles.actionLabel}>{t('email_viewer.forward', 'Forward')}</Text>
</Pressable>
</View>
@@ -159,13 +159,14 @@ function makeStyles(c: ThemePalette) {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
// Webmail thread card: outline buttons, h-9, rounded-md.
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.full,
minHeight: 36,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: 'transparent',
},
actionLabel: { ...typography.caption, color: c.textSecondary },
actionLabel: { ...typography.bodyMedium, color: c.text },
});
}
+1 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as WebBrowser from 'expo-web-browser';
import { MailMinus, CheckCircle2, AlertCircle, X } from 'lucide-react-native';
import { MailMinus, CheckCircle2, AlertCircle, X } from '../icons';
import type { Email } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
+4 -1
View File
@@ -25,6 +25,8 @@ interface Props {
/** The file to show; null closes the preview. */
file: PreviewableFileNode | null;
onClose: () => void;
/** Show it in a side pane instead of a full-screen modal. */
docked?: boolean;
}
/**
@@ -35,7 +37,7 @@ interface Props {
* of fetching it again. The copy is deleted when the preview closes, unless
* another app got it, in which case the stale-export sweep removes it.
*/
export function FilePreviewModal({ file, onClose }: Props) {
export function FilePreviewModal({ file, onClose, docked = false }: Props) {
const t = useLocaleStore((s) => s.t);
const [item, setItem] = React.useState<PreviewItem | null>(null);
const [loading, setLoading] = React.useState(false);
@@ -123,6 +125,7 @@ export function FilePreviewModal({ file, onClose }: Props) {
item={item}
loading={loading}
title={name}
docked={docked}
onClose={onClose}
onOpenExternal={() => act(
(local) => shareLocalFile(local, type, name, { keep: true }),
+21 -4
View File
@@ -11,13 +11,15 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Folder, FileText, Trash2, UserPlus, Users } from 'lucide-react-native';
import { Folder, FileText, Trash2, UserPlus, Users } from '../icons';
import { getPrincipals, isFolder, ownPrincipalId, setFileNodeShare } from '../../api/files';
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} />
+3 -2
View File
@@ -4,7 +4,7 @@ import {
KeyboardAvoidingView, Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { X, Plus, Trash2 } from 'lucide-react-native';
import { X, Plus, Trash2 } from '../icons';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Select, ToggleSwitch } from '../settings/settings-section';
@@ -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
+8 -5
View File
@@ -4,11 +4,12 @@ import {
KeyboardAvoidingView, Platform, ActivityIndicator,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { X, AlertTriangle, CheckCircle } from 'lucide-react-native';
import { X, AlertTriangle, CheckCircle } from '../icons';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
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,
@@ -0,0 +1,74 @@
// The app's icons are Tabler glyphs under the names the screens import.
// These tests make sure each name draws the glyph the webmail uses for it,
// and that every icon a screen imports exists.
import { describe, it, expect, vi } from 'vitest';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' }));
const ROOT = join(__dirname, '..', '..', '..', '..');
const WEBMAIL_ICONS = join(ROOT, '..', '..', 'components', 'icons', 'index.ts');
const TABLER = join(ROOT, '..', '..', 'node_modules', '@tabler', 'icons', 'tabler-nodes-outline.json');
function sourceFiles(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
if (statSync(p).isDirectory()) {
// The widgets draw their own SVG strings (src/widgets/icons.ts).
if (entry !== '__tests__' && entry !== 'node_modules' && entry !== 'widgets') sourceFiles(p, out);
} else if (/\.tsx?$/.test(entry)) {
out.push(p);
}
}
return out;
}
/** Path data the generated module hands to an icon, read off its element. */
function glyphOf(icon: unknown): string[] {
const memo = icon as { type: (p: object) => { props: { children: { props: { d: string } }[] } } };
return memo.type({}).props.children.map((child) => child.props.d);
}
describe('icons', () => {
it('exports every icon a screen imports', async () => {
const icons: Record<string, unknown> = await import('..');
const missing: string[] = [];
const files = [...sourceFiles(join(ROOT, 'src')), join(ROOT, 'App.tsx')];
const re = /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+'[./]*(?:components\/)?icons'/g;
let checked = 0;
for (const file of files) {
for (const m of readFileSync(file, 'utf8').matchAll(re)) {
for (const raw of m[1].split(',')) {
const spec = raw.trim().replace(/^type\s+/, '');
if (!spec) continue;
const name = spec.split(/\s+as\s+/)[0];
if (name === 'AppIcon' || name === 'IconProps') continue;
checked++;
if (typeof icons[name] !== 'object') missing.push(`${relative(ROOT, file)}: ${name}`);
}
}
}
expect(missing).toEqual([]);
expect(checked).toBeGreaterThan(100);
});
it.skipIf(!existsSync(WEBMAIL_ICONS) || !existsSync(TABLER))('draws the glyph the webmail uses for the same name', async () => {
const icons: Record<string, unknown> = await import('..');
const tabler: Record<string, [string, { d: string }][]> = JSON.parse(readFileSync(TABLER, 'utf8'));
const webmail = readFileSync(WEBMAIL_ICONS, 'utf8');
const kebab = (pascal: string) => pascal.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Za-z])(\d)/g, '$1-$2').toLowerCase();
const differ: string[] = [];
let compared = 0;
for (const m of webmail.matchAll(/Icon(\w+) as (\w+),/g)) {
const [, tablerPascal, name] = m;
if (!(name in icons)) continue;
const expected = tabler[kebab(tablerPascal)]?.map(([, a]) => a.d);
if (!expected) continue;
compared++;
if (JSON.stringify(glyphOf(icons[name])) !== JSON.stringify(expected)) differ.push(`${name} (webmail: ${kebab(tablerPascal)})`);
}
expect(differ).toEqual([]);
expect(compared).toBeGreaterThan(100);
});
});
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
import type { StyleProp, ViewStyle } from 'react-native';
import Svg, { Path } from 'react-native-svg';
export interface IconProps {
size?: number;
color?: string;
/** Tabler draws at 2 on a 24 grid, like the webmail. */
strokeWidth?: number;
/** Fills the outline, for a starred star or a coloured tag. */
fill?: string;
style?: StyleProp<ViewStyle>;
testID?: string;
}
export type AppIcon = React.ComponentType<IconProps>;
/** An icon component for one Tabler outline glyph (its path data). */
export function makeIcon(name: string, paths: readonly string[]): AppIcon {
if (!paths?.length) throw new Error(`Icon ${name} has no glyph`);
function Icon({ size = 24, color = '#000', strokeWidth = 2, fill = 'none', style, testID }: IconProps) {
return (
<Svg
width={size}
height={size}
viewBox="0 0 24 24"
fill={fill}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
style={style}
testID={testID}
>
{paths.map((d, i) => <Path key={i} d={d} />)}
</Svg>
);
}
Icon.displayName = name;
return React.memo(Icon);
}
+357
View File
@@ -0,0 +1,357 @@
// Generated by scripts/generate-icons.mjs from @tabler/icons (MIT).
// Do not edit by hand: add the icon to MAP in the script and run it.
import { makeIcon } from './icon';
export type { AppIcon, IconProps } from './icon';
const G = {
'address-book': ["M20 6v12a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2","M10 16h6","M11 11a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M4 8h3","M4 12h3","M4 16h3"],
'alarm': ["M5 13a7 7 0 1 0 14 0a7 7 0 1 0 -14 0","M12 10l0 3l2 0","M7 4l-2.75 2","M17 4l2.75 2"],
'alert-circle': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M12 8v4","M12 16h.01"],
'alert-triangle': ["M12 9v4","M10.363 3.591l-8.106 13.534a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636 -2.87l-8.106 -13.536a1.914 1.914 0 0 0 -3.274 0","M12 16h.01"],
'align-center': ["M4 6l16 0","M8 12l8 0","M6 18l12 0"],
'align-left': ["M4 6l16 0","M4 12l10 0","M4 18l14 0"],
'align-right': ["M4 6l16 0","M10 12l10 0","M6 18l14 0"],
'archive': ["M3 6a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2","M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-10","M10 12l4 0"],
'arrow-back': ["M9 11l-4 4l4 4m-4 -4h11a4 4 0 0 0 0 -8h-1"],
'arrow-back-up': ["M9 14l-4 -4l4 -4","M5 10h11a4 4 0 1 1 0 8h-1"],
'arrow-back-up-double': ["M13 14l-4 -4l4 -4","M8 14l-4 -4l4 -4","M9 10h7a4 4 0 1 1 0 8h-1"],
'arrow-down': ["M12 5l0 14","M18 13l-6 6","M6 13l6 6"],
'arrow-forward': ["M15 11l4 4l-4 4m4 -4h-11a4 4 0 0 1 0 -8h1"],
'arrow-forward-up': ["M15 14l4 -4l-4 -4","M19 10h-11a4 4 0 1 0 0 8h1"],
'arrow-left': ["M5 12l14 0","M5 12l6 6","M5 12l6 -6"],
'arrow-right': ["M5 12l14 0","M13 18l6 -6","M13 6l6 6"],
'arrow-up': ["M12 5l0 14","M18 11l-6 -6","M6 11l6 -6"],
'arrows-shuffle': ["M18 4l3 3l-3 3","M18 20l3 -3l-3 -3","M3 7h3a5 5 0 0 1 5 5a5 5 0 0 0 5 5h5","M21 7h-5a4.978 4.978 0 0 0 -3 1m-4 8a4.984 4.984 0 0 1 -3 1h-3"],
'arrows-sort': ["M3 9l4 -4l4 4m-4 -4v14","M21 15l-4 4l-4 -4m4 4v-14"],
'arrows-vertical': ["M8 7l4 -4l4 4","M8 17l4 4l4 -4","M12 3l0 18"],
'ban': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M5.7 5.7l12.6 12.6"],
'beach': ["M17.553 16.75a7.5 7.5 0 0 0 -10.606 0","M18 3.804a6 6 0 0 0 -8.196 2.196l10.392 6a6 6 0 0 0 -2.196 -8.196","M16.732 10c1.658 -2.87 2.225 -5.644 1.268 -6.196c-.957 -.552 -3.075 1.326 -4.732 4.196","M15 9l-3 5.196","M3 19.25a2.4 2.4 0 0 1 1 -.25a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 1 .25"],
'bell': ["M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1"],
'bell-ringing': ["M10 5a2 2 0 0 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1","M21 6.727a11.05 11.05 0 0 0 -2.794 -3.727","M3 6.727a11.05 11.05 0 0 1 2.792 -3.727"],
'bold': ["M7 5h6a3.5 3.5 0 0 1 0 7h-6l0 -7","M13 12h1a3.5 3.5 0 0 1 0 7h-7v-7"],
'book': ["M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0","M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0","M3 6l0 13","M12 6l0 13","M21 6l0 13"],
'book-2': ["M19 4v16h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12","M19 16h-12a2 2 0 0 0 -2 2","M9 8h6"],
'bug': ["M9 9v-1a3 3 0 0 1 6 0v1","M8 9h8a6 6 0 0 1 1 3v3a5 5 0 0 1 -10 0v-3a6 6 0 0 1 1 -3","M3 13l4 0","M17 13l4 0","M12 20l0 -6","M4 19l3.35 -2","M20 19l-3.35 -2","M4 7l3.75 2.4","M20 7l-3.75 2.4"],
'building': ["M3 21l18 0","M9 8l1 0","M9 12l1 0","M9 16l1 0","M14 8l1 0","M14 12l1 0","M14 16l1 0","M5 21v-16a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v16"],
'cake': ["M3 20h18v-8a3 3 0 0 0 -3 -3h-12a3 3 0 0 0 -3 3v8","M3 14.803c.312 .135 .654 .204 1 .197a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1c.35 .007 .692 -.062 1 -.197","M12 4l1.465 1.638a2 2 0 1 1 -3.015 .099l1.55 -1.737"],
'calendar': ["M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12","M16 3v4","M8 3v4","M4 11h16","M11 15h1","M12 15v3"],
'calendar-month': ["M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12","M16 3v4","M8 3v4","M4 11h16","M8 14v4","M12 14v4","M16 14v4"],
'calendar-plus': ["M12.5 21h-6.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v5","M16 3v4","M8 3v4","M4 11h16","M16 19h6","M19 16v6"],
'camera': ["M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2","M9 13a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"],
'check': ["M5 12l5 5l10 -10"],
'checks': ["M7 12l5 5l10 -10","M2 12l5 5m5 -5l5 -5"],
'chevron-down': ["M6 9l6 6l6 -6"],
'chevron-left': ["M15 6l-6 6l6 6"],
'chevron-right': ["M9 6l6 6l-6 6"],
'chevron-up': ["M6 15l6 -6l6 6"],
'circle-check': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M9 12l2 2l4 -4"],
'circle-x': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M10 10l4 4m0 -4l-4 4"],
'clear-formatting': ["M17 15l4 4m0 -4l-4 4","M7 6v-1h11v1","M7 19l4 0","M13 5l-4 14"],
'clipboard-text': ["M9 5h-2a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2h-2","M9 5a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2","M9 12h6","M9 16h6"],
'clock': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M12 7v5l3 3"],
'cloud-download': ["M19 18a3.5 3.5 0 0 0 0 -7h-1a5 4.5 0 0 0 -11 -2a4.6 4.4 0 0 0 -2.1 8.4","M12 13l0 9","M9 19l3 3l3 -3"],
'cloud-off': ["M9.58 5.548c.24 -.11 .492 -.207 .752 -.286c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 .957 -.383 1.824 -1.003 2.454m-2.997 1.033h-11.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.13 -.582 .37 -1.128 .7 -1.62","M3 3l18 18"],
'code': ["M7 8l-4 4l4 4","M17 8l4 4l-4 4","M14 4l-4 16"],
'columns-3': ["M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16","M9 3v18","M15 3v18"],
'copy': ["M7 9.667a2.667 2.667 0 0 1 2.667 -2.667h8.666a2.667 2.667 0 0 1 2.667 2.667v8.666a2.667 2.667 0 0 1 -2.667 2.667h-8.666a2.667 2.667 0 0 1 -2.667 -2.667l0 -8.666","M4.012 16.737a2.005 2.005 0 0 1 -1.012 -1.737v-10c0 -1.1 .9 -2 2 -2h10c.75 0 1.158 .385 1.5 1"],
'database': ["M4 6a8 3 0 1 0 16 0a8 3 0 1 0 -16 0","M4 6v6a8 3 0 0 0 16 0v-6","M4 12v6a8 3 0 0 0 16 0v-6"],
'device-desktop': ["M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-10","M7 20h10","M9 16v4","M15 16v4"],
'device-floppy': ["M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2","M10 14a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M14 4l0 4l-6 0l0 -4"],
'device-mobile': ["M6 5a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2v-14","M11 4h2","M12 17v.01"],
'dots': ["M4 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M18 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
'dots-vertical': ["M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
'download': ["M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2","M7 11l5 5l5 -5","M12 4l0 12"],
'edit': ["M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1","M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415","M16 5l3 3"],
'eraser': ["M19 20h-10.5l-4.21 -4.3a1 1 0 0 1 0 -1.41l10 -10a1 1 0 0 1 1.41 0l5 5a1 1 0 0 1 0 1.41l-9.2 9.3","M18 13.3l-6.3 -6.3"],
'external-link': ["M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6","M11 13l9 -9","M15 4h5v5"],
'eye': ["M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0","M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6"],
'eye-off': ["M10.585 10.587a2 2 0 0 0 2.829 2.828","M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87","M3 3l18 18"],
'file': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"],
'file-code': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2","M10 13l-1 2l1 2","M14 13l1 2l-1 2"],
'file-code-2': ["M10 12h-1v5h1","M14 12h1v5h-1","M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"],
'file-import': ["M14 3v4a1 1 0 0 0 1 1h4","M5 13v-8a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2h-5.5m-9.5 -2h7m-3 -3l3 3l-3 3"],
'file-music': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2","M10 16a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M12 16l0 -5l2 1"],
'file-spreadsheet': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2","M8 11h8v7h-8l0 -7","M8 15h8","M11 11v7"],
'file-text': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2","M9 9l1 0","M9 13l6 0","M9 17l6 0"],
'file-zip': ["M6 20.735a2 2 0 0 1 -1 -1.735v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2h-1","M11 17a2 2 0 0 1 2 2v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a2 2 0 0 1 2 -2","M11 5l-1 0","M13 7l-1 0","M11 9l-1 0","M13 11l-1 0","M11 13l-1 0","M13 15l-1 0"],
'filter': ["M4 4h16v2.172a2 2 0 0 1 -.586 1.414l-4.414 4.414v7l-6 2v-8.5l-4.48 -4.928a2 2 0 0 1 -.52 -1.345v-2.227"],
'flag': ["M5 5a5 5 0 0 1 7 0a5 5 0 0 0 7 0v9a5 5 0 0 1 -7 0a5 5 0 0 0 -7 0v-9","M5 21v-7"],
'flask': ["M9 3l6 0","M10 9l4 0","M10 3v6l-4 11a.7 .7 0 0 0 .5 1h11a.7 .7 0 0 0 .5 -1l-4 -11v-6"],
'folder': ["M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2"],
'folder-down': ["M12 19h-7a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v3.5","M19 16v6","M22 19l-3 3l-3 -3"],
'folder-open': ["M5 19l2.757 -7.351a1 1 0 0 1 .936 -.649h12.307a1 1 0 0 1 .986 1.164l-.996 5.211a2 2 0 0 1 -1.964 1.625h-14.026a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v2"],
'folder-plus': ["M12 19h-7a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v3.5","M16 19h6","M19 16v6"],
'folders': ["M9 3h3l2 2h5a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2","M17 16v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2h2"],
'grid-dots': ["M4 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M18 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M4 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M18 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M4 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M18 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
'grip-vertical': ["M8 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M8 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M8 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M14 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M14 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M14 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
'h-1': ["M19 18v-8l-2 2","M4 6v12","M12 6v12","M11 18h2","M3 18h2","M4 12h8","M3 6h2","M11 6h2"],
'h-2': ["M17 12a2 2 0 1 1 4 0c0 .591 -.417 1.318 -.816 1.858l-3.184 4.143l4 0","M4 6v12","M12 6v12","M11 18h2","M3 18h2","M4 12h8","M3 6h2","M11 6h2"],
'heart': ["M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572"],
'help-circle': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M12 16v.01","M12 13a2 2 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"],
'highlight': ["M3 19h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4","M12.5 5.5l4 4","M4.5 13.5l4 4","M21 15v4h-8l4 -4l4 0"],
'inbox': ["M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12","M4 13h3l3 3h4l3 -3h3"],
'info-circle': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M12 9h.01","M11 12h1v4h1"],
'italic': ["M11 5l6 0","M7 19l6 0","M14 5l-4 14"],
'key': ["M16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1 -4.069 0l-.301 -.301l-6.558 6.558a2 2 0 0 1 -1.239 .578l-.175 .008h-1.172a1 1 0 0 1 -.993 -.883l-.007 -.117v-1.172a2 2 0 0 1 .467 -1.284l.119 -.13l.414 -.414h2v-2h2v-2l2.144 -2.144l-.301 -.301a2.877 2.877 0 0 1 0 -4.069l2.643 -2.643a2.877 2.877 0 0 1 4.069 0","M15 9h.01"],
'language': ["M9 6.371c0 4.418 -2.239 6.629 -5 6.629","M4 6.371h7","M5 9c0 2.144 2.252 3.908 6 4","M12 20l4 -9l4 9","M19.1 18h-6.2","M6.694 3l.793 .582"],
'layout-columns': ["M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12","M12 4l0 16"],
'layout-grid': ["M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M14 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M14 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4"],
'layout-rows': ["M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12","M4 12l16 0"],
'layout-sidebar-left-collapse': ["M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12","M9 4v16","M15 10l-2 2l2 2"],
'layout-sidebar-right': ["M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12","M15 4l0 16"],
'link': ["M9 15l6 -6","M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464","M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"],
'list': ["M9 6l11 0","M9 12l11 0","M9 18l11 0","M5 6l0 .01","M5 12l0 .01","M5 18l0 .01"],
'list-check': ["M3.5 5.5l1.5 1.5l2.5 -2.5","M3.5 11.5l1.5 1.5l2.5 -2.5","M3.5 17.5l1.5 1.5l2.5 -2.5","M11 6l9 0","M11 12l9 0","M11 18l9 0"],
'list-numbers': ["M11 6h9","M11 12h9","M12 18h8","M4 16a2 2 0 1 1 4 0c0 .591 -.5 1 -1 1.5l-3 2.5h4","M6 10v-6l-2 2"],
'lock': ["M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6","M11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0","M8 11v-4a4 4 0 1 1 8 0v4"],
'logout': ["M14 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2","M9 12h12l-3 -3","M18 15l3 -3"],
'mail': ["M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10","M3 7l9 6l9 -6"],
'mail-check': ["M11 19h-6a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v6","M3 7l9 6l9 -6","M15 19l2 2l4 -4"],
'mail-minus': ["M12 19h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v8","M16 19h6","M3 7l9 6l9 -6"],
'mail-opened': ["M3 9l9 6l9 -6l-9 -6l-9 6","M21 9v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10","M3 19l6 -6","M15 13l6 6"],
'map-pin': ["M9 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0","M17.657 16.657l-4.243 4.243a2 2 0 0 1 -2.827 0l-4.244 -4.243a8 8 0 1 1 11.314 0"],
'maximize': ["M4 8v-2a2 2 0 0 1 2 -2h2","M4 16v2a2 2 0 0 0 2 2h2","M16 4h2a2 2 0 0 1 2 2v2","M16 20h2a2 2 0 0 0 2 -2v-2"],
'menu-2': ["M4 6l16 0","M4 12l16 0","M4 18l16 0"],
'message': ["M8 9h8","M8 13h6","M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12"],
'minus': ["M5 12l14 0"],
'moon': ["M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454l0 .008"],
'note': ["M13 20l7 -7","M13 20v-6a1 1 0 0 1 1 -1h6v-7a2 2 0 0 0 -2 -2h-12a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7"],
'palette': ["M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25","M7.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M15.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
'paperclip': ["M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"],
'pencil': ["M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4","M13.5 6.5l4 4"],
'phone': ["M5 4h4l2 5l-2.5 1.5a11 11 0 0 0 5 5l1.5 -2.5l5 2v4a2 2 0 0 1 -2 2a16 16 0 0 1 -15 -15a2 2 0 0 1 2 -2"],
'photo': ["M15 8h.01","M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12","M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5","M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"],
'pinned': ["M9 4v6l-2 4v2h10v-2l-2 -4v-6","M12 16l0 5","M8 4l8 0"],
'pinned-off': ["M3 3l18 18","M15 4.5l-3.249 3.249m-2.57 1.433l-2.181 .818l-1.5 1.5l7 7l1.5 -1.5l.82 -2.186m1.43 -2.563l3.25 -3.251","M9 15l-4.5 4.5","M14.5 4l5.5 5.5"],
'plus': ["M12 5l0 14","M5 12l14 0"],
'puzzle': ["M4 7h3a1 1 0 0 0 1 -1v-1a2 2 0 0 1 4 0v1a1 1 0 0 0 1 1h3a1 1 0 0 1 1 1v3a1 1 0 0 0 1 1h1a2 2 0 0 1 0 4h-1a1 1 0 0 0 -1 1v3a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-1a2 2 0 0 0 -4 0v1a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h1a2 2 0 0 0 0 -4h-1a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1"],
'qrcode': ["M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M7 17l0 .01","M14 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M7 7l0 .01","M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M17 7l0 .01","M14 14l3 0","M20 14l0 .01","M14 14l0 3","M14 20l3 0","M17 17l3 0","M20 17l0 3"],
'quote': ["M10 11h-4a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h3a1 1 0 0 1 1 1v6c0 2.667 -1.333 4.333 -4 5","M19 11h-4a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h3a1 1 0 0 1 1 1v6c0 2.667 -1.333 4.333 -4 5"],
'refresh': ["M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4","M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"],
'repeat': ["M4 12v-3a3 3 0 0 1 3 -3h13m-3 -3l3 3l-3 3","M20 12v3a3 3 0 0 1 -3 3h-13m3 3l-3 -3l3 -3"],
'rotate': ["M19.95 11a8 8 0 1 0 -.5 4m.5 5v-5h-5"],
'rss': ["M4 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M4 4a16 16 0 0 1 16 16","M4 11a9 9 0 0 1 9 9"],
'scale': ["M7 20l10 0","M6 6l6 -1l6 1","M12 3l0 17","M9 12l-3 -6l-3 6a3 3 0 0 0 6 0","M21 12l-3 -6l-3 6a3 3 0 0 0 6 0"],
'search': ["M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0","M21 21l-6 -6"],
'send': ["M10 14l11 -11","M21 3l-6.5 18a.55 .55 0 0 1 -1 0l-3.5 -7l-7 -3.5a.55 .55 0 0 1 0 -1l18 -6.5"],
'server': ["M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-2","M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2","M7 8l0 .01","M7 16l0 .01"],
'server-2': ["M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-2","M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2","M7 8l0 .01","M7 16l0 .01","M11 8h6","M11 16h6"],
'settings': ["M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065","M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"],
'share-2': ["M8 9h-1a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-8a2 2 0 0 0 -2 -2h-1","M12 14v-11","M9 6l3 -3l3 3"],
'shield': ["M12 3a12 12 0 0 0 8.5 3a12 12 0 0 1 -8.5 15a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3"],
'shield-check': ["M11.46 20.846a12 12 0 0 1 -7.96 -14.846a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3a12 12 0 0 1 -.09 7.06","M15 19l2 2l4 -4"],
'shield-exclamation': ["M15.04 19.745c-.942 .551 -1.964 .976 -3.04 1.255a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3a12 12 0 0 1 .195 6.015","M19 16v3","M19 22v.01"],
'shield-question': ["M15.065 19.732c-.95 .557 -1.98 .986 -3.065 1.268a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3c.51 1.738 .617 3.55 .333 5.303","M19 22v.01","M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"],
'sort-ascending': ["M4 6l7 0","M4 12l7 0","M4 18l9 0","M15 9l3 -3l3 3","M18 6l0 12"],
'sort-descending': ["M4 6l9 0","M4 12l7 0","M4 18l7 0","M15 15l3 3l3 -3","M18 6l0 12"],
'square': ["M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14"],
'square-check': ["M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14","M9 12l2 2l4 -4"],
'stack': ["M12 6l-8 4l8 4l8 -4l-8 -4","M4 14l8 4l8 -4"],
'star': ["M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873l-6.158 -3.245"],
'strikethrough': ["M5 12l14 0","M16 6.5a4 2 0 0 0 -4 -1.5h-1a3.5 3.5 0 0 0 0 7h2a3.5 3.5 0 0 1 0 7h-1.5a4 2 0 0 1 -4 -1.5"],
'sun': ["M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0","M3 12h1m8 -9v1m8 8h1m-9 8v1m-6.4 -15.4l.7 .7m12.1 -.7l-.7 .7m0 11.4l.7 .7m-12.1 -.7l-.7 .7"],
'table': ["M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14","M3 10h18","M10 3v18"],
'tag': ["M6.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M3 6v5.172a2 2 0 0 0 .586 1.414l7.71 7.71a2.41 2.41 0 0 0 3.408 0l5.592 -5.592a2.41 2.41 0 0 0 0 -3.408l-7.71 -7.71a2 2 0 0 0 -1.414 -.586h-5.172a3 3 0 0 0 -3 3"],
'tags': ["M3 8v4.172a2 2 0 0 0 .586 1.414l5.71 5.71a2.41 2.41 0 0 0 3.408 0l3.592 -3.592a2.41 2.41 0 0 0 0 -3.408l-5.71 -5.71a2 2 0 0 0 -1.414 -.586h-4.172a2 2 0 0 0 -2 2","M18 19l1.592 -1.592a4.82 4.82 0 0 0 0 -6.816l-4.592 -4.592","M7 10h-.01"],
'template': ["M4 5a1 1 0 0 1 1 -1h14a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-14a1 1 0 0 1 -1 -1l0 -2","M4 13a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -6","M14 12l6 0","M14 16l6 0","M14 20l6 0"],
'terminal': ["M5 7l5 5l-5 5","M12 19l7 0"],
'trash': ["M4 7l16 0","M10 11l0 6","M14 11l0 6","M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12","M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"],
'typography': ["M4 20l3 0","M14 20l7 0","M6.9 15l6.9 0","M10.2 6.3l5.8 13.7","M5 20l6 -16l2 0l7 16"],
'underline': ["M7 5v5a5 5 0 0 0 10 0v-5","M5 19h14"],
'unlink': ["M17 22v-2","M9 15l6 -6","M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464","M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463","M20 17h2","M2 7h2","M7 2v2"],
'upload': ["M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2","M7 9l5 -5l5 5","M12 4l0 12"],
'user': ["M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0","M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"],
'user-circle': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M9 10a3 3 0 1 0 6 0a3 3 0 1 0 -6 0","M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855"],
'user-edit': ["M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0","M6 21v-2a4 4 0 0 1 4 -4h3.5","M18.42 15.61a2.1 2.1 0 0 1 2.97 2.97l-3.39 3.42h-3v-3l3.42 -3.39"],
'user-minus': ["M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0","M6 21v-2a4 4 0 0 1 4 -4h4c.348 0 .686 .045 1.009 .128","M16 19h6"],
'user-plus': ["M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0","M16 19h6","M19 16v6","M6 21v-2a4 4 0 0 1 4 -4h4"],
'users': ["M5 7a4 4 0 1 0 8 0a4 4 0 1 0 -8 0","M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2","M16 3.13a4 4 0 0 1 0 7.75","M21 21v-2a4 4 0 0 0 -3 -3.85"],
'video': ["M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4","M3 8a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2l0 -8"],
'world': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M3.6 9h16.8","M3.6 15h16.8","M11.5 3a17 17 0 0 0 0 18","M12.5 3a17 17 0 0 1 0 18"],
'x': ["M18 6l-12 12","M6 6l12 12"],
'zoom-scan': ["M8 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0","M16 16l-2.5 -2.5","M3 7v-2a2 2 0 0 1 2 -2h2","M3 17v2a2 2 0 0 0 2 2h2","M17 3h2a2 2 0 0 1 2 2v2","M17 21h2a2 2 0 0 0 2 -2v-2"],
} as const;
export const AlarmClock = makeIcon('AlarmClock', G['alarm']);
export const AlertCircle = makeIcon('AlertCircle', G['alert-circle']);
export const AlertTriangle = makeIcon('AlertTriangle', G['alert-triangle']);
export const AlignCenter = makeIcon('AlignCenter', G['align-center']);
export const AlignLeft = makeIcon('AlignLeft', G['align-left']);
export const AlignRight = makeIcon('AlignRight', G['align-right']);
export const Archive = makeIcon('Archive', G['archive']);
export const ArrowDown = makeIcon('ArrowDown', G['arrow-down']);
export const ArrowDownWideNarrow = makeIcon('ArrowDownWideNarrow', G['sort-descending']);
export const ArrowLeft = makeIcon('ArrowLeft', G['arrow-left']);
export const ArrowRight = makeIcon('ArrowRight', G['arrow-right']);
export const ArrowUp = makeIcon('ArrowUp', G['arrow-up']);
export const ArrowUpDown = makeIcon('ArrowUpDown', G['arrows-sort']);
export const ArrowUpNarrowWide = makeIcon('ArrowUpNarrowWide', G['sort-ascending']);
export const Ban = makeIcon('Ban', G['ban']);
export const Bell = makeIcon('Bell', G['bell']);
export const BellRing = makeIcon('BellRing', G['bell-ringing']);
export const Bold = makeIcon('Bold', G['bold']);
export const Book = makeIcon('Book', G['book-2']);
export const BookOpen = makeIcon('BookOpen', G['book']);
export const BookUser = makeIcon('BookUser', G['address-book']);
export const Bug = makeIcon('Bug', G['bug']);
export const Building = makeIcon('Building', G['building']);
export const Cake = makeIcon('Cake', G['cake']);
export const Calendar = makeIcon('Calendar', G['calendar']);
export const CalendarDays = makeIcon('CalendarDays', G['calendar-month']);
export const CalendarPlus = makeIcon('CalendarPlus', G['calendar-plus']);
export const Camera = makeIcon('Camera', G['camera']);
export const Check = makeIcon('Check', G['check']);
export const CheckCheck = makeIcon('CheckCheck', G['checks']);
export const CheckCircle = makeIcon('CheckCircle', G['circle-check']);
export const CheckCircle2 = makeIcon('CheckCircle2', G['circle-check']);
export const CheckSquare = makeIcon('CheckSquare', G['square-check']);
export const ChevronDown = makeIcon('ChevronDown', G['chevron-down']);
export const ChevronLeft = makeIcon('ChevronLeft', G['chevron-left']);
export const ChevronRight = makeIcon('ChevronRight', G['chevron-right']);
export const ChevronUp = makeIcon('ChevronUp', G['chevron-up']);
export const CircleCheck = makeIcon('CircleCheck', G['circle-check']);
export const ClipboardPaste = makeIcon('ClipboardPaste', G['clipboard-text']);
export const Clock = makeIcon('Clock', G['clock']);
export const CloudDownload = makeIcon('CloudDownload', G['cloud-download']);
export const CloudOff = makeIcon('CloudOff', G['cloud-off']);
export const Code = makeIcon('Code', G['code']);
export const Columns3 = makeIcon('Columns3', G['columns-3']);
export const Columns4 = makeIcon('Columns4', G['layout-columns']);
export const Copy = makeIcon('Copy', G['copy']);
export const Database = makeIcon('Database', G['database']);
export const Download = makeIcon('Download', G['download']);
export const Edit2 = makeIcon('Edit2', G['pencil']);
export const Eraser = makeIcon('Eraser', G['eraser']);
export const ExternalLink = makeIcon('ExternalLink', G['external-link']);
export const Eye = makeIcon('Eye', G['eye']);
export const EyeOff = makeIcon('EyeOff', G['eye-off']);
export const File = makeIcon('File', G['file']);
export const FileArchive = makeIcon('FileArchive', G['file-zip']);
export const FileAudio = makeIcon('FileAudio', G['file-music']);
export const FileCode = makeIcon('FileCode', G['file-code']);
export const FileCode2 = makeIcon('FileCode2', G['file-code-2']);
export const FileImage = makeIcon('FileImage', G['photo']);
export const FileInput = makeIcon('FileInput', G['file-import']);
export const FileSpreadsheet = makeIcon('FileSpreadsheet', G['file-spreadsheet']);
export const FileText = makeIcon('FileText', G['file-text']);
export const FileVideo = makeIcon('FileVideo', G['video']);
export const Filter = makeIcon('Filter', G['filter']);
export const Flag = makeIcon('Flag', G['flag']);
export const FlaskConical = makeIcon('FlaskConical', G['flask']);
export const Folder = makeIcon('Folder', G['folder']);
export const FolderArchive = makeIcon('FolderArchive', G['archive']);
export const FolderInput = makeIcon('FolderInput', G['folder-down']);
export const FolderOpen = makeIcon('FolderOpen', G['folder-open']);
export const FolderPlus = makeIcon('FolderPlus', G['folder-plus']);
export const FolderSync = makeIcon('FolderSync', G['folders']);
export const Forward = makeIcon('Forward', G['arrow-forward-up']);
export const Globe = makeIcon('Globe', G['world']);
export const Grid3x3 = makeIcon('Grid3x3', G['grid-dots']);
export const GripVertical = makeIcon('GripVertical', G['grip-vertical']);
export const HardDrive = makeIcon('HardDrive', G['server-2']);
export const Heading1 = makeIcon('Heading1', G['h-1']);
export const Heading2 = makeIcon('Heading2', G['h-2']);
export const Heart = makeIcon('Heart', G['heart']);
export const HelpCircle = makeIcon('HelpCircle', G['help-circle']);
export const Highlighter = makeIcon('Highlighter', G['highlight']);
export const Image = makeIcon('Image', G['photo']);
export const Import = makeIcon('Import', G['file-import']);
export const Inbox = makeIcon('Inbox', G['inbox']);
export const Info = makeIcon('Info', G['info-circle']);
export const Italic = makeIcon('Italic', G['italic']);
export const Key = makeIcon('Key', G['key']);
export const KeyRound = makeIcon('KeyRound', G['key']);
export const Languages = makeIcon('Languages', G['language']);
export const Layers = makeIcon('Layers', G['stack']);
export const LayoutGrid = makeIcon('LayoutGrid', G['layout-grid']);
export const LayoutTemplate = makeIcon('LayoutTemplate', G['template']);
export const Link2 = makeIcon('Link2', G['link']);
export const Link2Off = makeIcon('Link2Off', G['unlink']);
export const List = makeIcon('List', G['list']);
export const ListChecks = makeIcon('ListChecks', G['list-check']);
export const ListOrdered = makeIcon('ListOrdered', G['list-numbers']);
export const Lock = makeIcon('Lock', G['lock']);
export const LogOut = makeIcon('LogOut', G['logout']);
export const Mail = makeIcon('Mail', G['mail']);
export const MailCheck = makeIcon('MailCheck', G['mail-check']);
export const MailMinus = makeIcon('MailMinus', G['mail-minus']);
export const MailOpen = makeIcon('MailOpen', G['mail-opened']);
export const Mails = makeIcon('Mails', G['inbox']);
export const MapPin = makeIcon('MapPin', G['map-pin']);
export const Maximize2 = makeIcon('Maximize2', G['maximize']);
export const Menu = makeIcon('Menu', G['menu-2']);
export const MessageSquare = makeIcon('MessageSquare', G['message']);
export const Minus = makeIcon('Minus', G['minus']);
export const Monitor = makeIcon('Monitor', G['device-desktop']);
export const Moon = makeIcon('Moon', G['moon']);
export const MoreHorizontal = makeIcon('MoreHorizontal', G['dots']);
export const MoreVertical = makeIcon('MoreVertical', G['dots-vertical']);
export const MoveVertical = makeIcon('MoveVertical', G['arrows-vertical']);
export const Palette = makeIcon('Palette', G['palette']);
export const Palmtree = makeIcon('Palmtree', G['beach']);
export const PanelLeftClose = makeIcon('PanelLeftClose', G['layout-sidebar-left-collapse']);
export const PanelRight = makeIcon('PanelRight', G['layout-sidebar-right']);
export const Paperclip = makeIcon('Paperclip', G['paperclip']);
export const PenLine = makeIcon('PenLine', G['pencil']);
export const PenSquare = makeIcon('PenSquare', G['edit']);
export const Pencil = makeIcon('Pencil', G['pencil']);
export const Phone = makeIcon('Phone', G['phone']);
export const Pin = makeIcon('Pin', G['pinned']);
export const PinOff = makeIcon('PinOff', G['pinned-off']);
export const Plus = makeIcon('Plus', G['plus']);
export const Puzzle = makeIcon('Puzzle', G['puzzle']);
export const QrCode = makeIcon('QrCode', G['qrcode']);
export const Quote = makeIcon('Quote', G['quote']);
export const Redo2 = makeIcon('Redo2', G['arrow-forward']);
export const RefreshCw = makeIcon('RefreshCw', G['refresh']);
export const RemoveFormatting = makeIcon('RemoveFormatting', G['clear-formatting']);
export const Repeat = makeIcon('Repeat', G['repeat']);
export const Reply = makeIcon('Reply', G['arrow-back-up']);
export const ReplyAll = makeIcon('ReplyAll', G['arrow-back-up-double']);
export const RotateCcw = makeIcon('RotateCcw', G['rotate']);
export const Rows2 = makeIcon('Rows2', G['layout-rows']);
export const Rss = makeIcon('Rss', G['rss']);
export const Save = makeIcon('Save', G['device-floppy']);
export const ScanSearch = makeIcon('ScanSearch', G['zoom-scan']);
export const Scale = makeIcon('Scale', G['scale']);
export const Search = makeIcon('Search', G['search']);
export const Send = makeIcon('Send', G['send']);
export const Server = makeIcon('Server', G['server']);
export const Settings = makeIcon('Settings', G['settings']);
export const Share2 = makeIcon('Share2', G['share-2']);
export const Shield = makeIcon('Shield', G['shield']);
export const ShieldAlert = makeIcon('ShieldAlert', G['shield-exclamation']);
export const ShieldCheck = makeIcon('ShieldCheck', G['shield-check']);
export const ShieldQuestion = makeIcon('ShieldQuestion', G['shield-question']);
export const Shuffle = makeIcon('Shuffle', G['arrows-shuffle']);
export const Smartphone = makeIcon('Smartphone', G['device-mobile']);
export const Square = makeIcon('Square', G['square']);
export const SquareCheck = makeIcon('SquareCheck', G['square-check']);
export const SquarePen = makeIcon('SquarePen', G['edit']);
export const Star = makeIcon('Star', G['star']);
export const StickyNote = makeIcon('StickyNote', G['note']);
export const Strikethrough = makeIcon('Strikethrough', G['strikethrough']);
export const Sun = makeIcon('Sun', G['sun']);
export const Table = makeIcon('Table', G['table']);
export const Tag = makeIcon('Tag', G['tag']);
export const Tags = makeIcon('Tags', G['tags']);
export const Terminal = makeIcon('Terminal', G['terminal']);
export const Trash = makeIcon('Trash', G['trash']);
export const Trash2 = makeIcon('Trash2', G['trash']);
export const Type = makeIcon('Type', G['typography']);
export const Underline = makeIcon('Underline', G['underline']);
export const Undo2 = makeIcon('Undo2', G['arrow-back']);
export const Upload = makeIcon('Upload', G['upload']);
export const User = makeIcon('User', G['user']);
export const UserCircle = makeIcon('UserCircle', G['user-circle']);
export const UserMinus = makeIcon('UserMinus', G['user-minus']);
export const UserPen = makeIcon('UserPen', G['user-edit']);
export const UserPlus = makeIcon('UserPlus', G['user-plus']);
export const UserRound = makeIcon('UserRound', G['user']);
export const UserRoundPlus = makeIcon('UserRoundPlus', G['user-plus']);
export const Users = makeIcon('Users', G['users']);
export const Video = makeIcon('Video', G['video']);
export const X = makeIcon('X', G['x']);
export const XCircle = makeIcon('XCircle', G['circle-x']);
@@ -1,486 +0,0 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Linking, Alert, ActivityIndicator } from 'react-native';
import Constants from 'expo-constants';
import * as DocumentPicker from 'expo-document-picker';
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { CloudDownload, ExternalLink } from 'lucide-react-native';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import Button from '../Button';
import Dialog from '../Dialog';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { ALL_DEBUG_CATEGORIES, useSettingsStore, type DebugCategory } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
import { useOutboxStore } from '../../stores/outbox-store';
import { useUpdatesStore } from '../../stores/updates-store';
import { runOfflineSync, formatBytes } from '../../lib/offline-sync';
import { clearCachedData } from '../../lib/clear-cached-data';
import { supportsSideloadUpdates } from '../../lib/platform-capabilities';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
const GIT_COMMIT = (Constants.expoConfig?.extra as { commit?: string } | undefined)?.commit ?? 'dev';
const APP_STORE_URL = 'https://github.com/bulwarkmail/native/releases';
export function AboutDataSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const resetToDefaults = useSettingsStore((s) => s.resetToDefaults);
const exportSettings = useSettingsStore((s) => s.exportSettings);
const importSettings = useSettingsStore((s) => s.importSettings);
const debugMode = useSettingsStore((s) => s.debugMode);
const debugCategories = useSettingsStore((s) => s.debugCategories);
const [confirmReset, setConfirmReset] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const offlineEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
const offlineDays = useSettingsStore((s) => s.offlineCacheDays);
const offlineMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const queuedChanges = useOutboxStore((s) => s.entries.length);
const flushOutbox = useOutboxStore((s) => s.flush);
const cacheCount = useOfflineCacheStore((s) => s.totalCount());
const cacheBytes = useOfflineCacheStore((s) => s.totalSize());
const cacheHydrated = useOfflineCacheStore((s) => s.hydrated);
const cacheHydrate = useOfflineCacheStore((s) => s.hydrate);
const sync = useOfflineCacheStore((s) => s.sync);
const clearAllCache = useOfflineCacheStore((s) => s.clearAll);
const syncBusy = sync.phase === 'scanning' || sync.phase === 'fetching';
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
const latestRelease = useUpdatesStore((s) => s.cachedLatest);
const updateAvailable = hasUpdate();
const updateSeverity = latestRelease?.severity ?? 'normal';
useEffect(() => {
if (!hydrated) void hydrate();
if (!cacheHydrated) void cacheHydrate();
}, [hydrated, hydrate, cacheHydrated, cacheHydrate]);
const dayOptions = [1, 3, 7, 14, 30, 90].map((d) => ({
value: String(d),
label: d === 1
? t('settings.offline.window_24h', 'Last 24 hours')
: t('settings.offline.window_days', 'Last {count} days', { count: d }),
}));
const maxMbOptions = [25, 50, 100, 250, 500].map((mb) => ({ value: String(mb), label: `${mb} MB` }));
const formatRelativeTime = (ms: number | undefined): string => {
if (!ms) return t('settings.offline.never', 'never');
const diff = Date.now() - ms;
if (diff < 60_000) return t('settings.offline.just_now', 'just now');
if (diff < 60 * 60_000) return t('settings.offline.minutes_ago', '{count} min ago', { count: Math.round(diff / 60_000) });
if (diff < 24 * 60 * 60_000) return t('settings.offline.hours_ago', '{count} h ago', { count: Math.round(diff / (60 * 60_000)) });
return t('settings.offline.days_ago', '{count} d ago', { count: Math.round(diff / (24 * 60 * 60_000)) });
};
const handleSyncNow = () => {
void runOfflineSync({ days: offlineDays, maxMB: offlineMaxMB });
// Also drain any queued offline changes while we're at it.
void flushOutbox();
};
const handleClearCache = () => {
Alert.alert(
t('settings.offline.clear_title', 'Clear offline mail'),
t(
'settings.offline.clear_message',
'Remove {count, plural, one {# cached message} other {# cached messages}} ({size})?',
{ count: cacheCount, size: formatBytes(cacheBytes) },
),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{ text: t('settings.offline.clear', 'Clear'), style: 'destructive', onPress: () => { void clearAllCache(); } },
],
);
};
const handleReset = () => {
setConfirmReset(false);
resetToDefaults();
Alert.alert(
t('settings.advanced.reset_settings.label', 'Reset Settings'),
t('settings.advanced.reset_settings.done', 'All settings have been restored to their defaults.'),
);
};
const handleExport = async () => {
try {
const json = exportSettings();
const file = new File(Paths.cache, `bulwark-settings-${new Date().toISOString().slice(0, 10)}.json`);
file.write(json);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(file.uri, {
mimeType: 'application/json',
dialogTitle: t('settings.advanced.export_settings.label', 'Export Settings'),
});
} else {
Alert.alert(
t('settings.advanced.export_settings.label', 'Export Settings'),
t('settings.advanced.export_settings.saved_to', 'Saved to {path}', { path: file.uri }),
);
}
} catch (err) {
Alert.alert(
t('settings.advanced.export_settings.label', 'Export Settings'),
err instanceof Error ? err.message : String(err),
);
}
};
const handleImport = async () => {
try {
const picked = await DocumentPicker.getDocumentAsync({
type: ['application/json', 'text/plain', '*/*'],
copyToCacheDirectory: true,
multiple: false,
});
if (picked.canceled || !picked.assets?.[0]) return;
const json = await new File(picked.assets[0].uri).text();
const ok = importSettings(json);
Alert.alert(
t('settings.advanced.import_settings.label', 'Import Settings'),
ok
? t('settings.import_success', 'Settings imported successfully')
: t('settings.import_error', 'Failed to import settings'),
);
} catch (err) {
Alert.alert(
t('settings.advanced.import_settings.label', 'Import Settings'),
err instanceof Error ? err.message : t('settings.import_error', 'Failed to import settings'),
);
}
};
const handleRefreshCache = async () => {
setRefreshing(true);
try {
await clearCachedData();
} catch (err) {
Alert.alert(
t('settings.advanced.refresh_cache.label', 'Refresh cached data'),
err instanceof Error ? err.message : String(err),
);
} finally {
setRefreshing(false);
}
};
return (
<View style={styles.container}>
<View style={styles.aboutBox}>
<View style={styles.aboutRow}>
<View style={styles.logo}>
<Text style={styles.logoText}>B</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.aboutTitle}>{t('settings.advanced.about.mobile_title', 'Bulwark Mobile')}</Text>
<Text style={styles.aboutVersion}>
v{APP_VERSION}{' '}
<Text style={styles.aboutCommit}>({GIT_COMMIT})</Text>
</Text>
{updateAvailable && latestRelease && (
<Pressable
onPress={() => void Linking.openURL(latestRelease.htmlUrl || APP_STORE_URL)}
accessibilityRole="link"
style={[styles.updatePill, updateSeverity !== 'normal' && styles.updatePillSecurity]}
>
<Text style={[styles.updatePillText, updateSeverity !== 'normal' && styles.updatePillTextSecurity]}>
{updateSeverity === 'security'
? t('settings.advanced.about.security_update', 'Security update: {version}', { version: latestRelease.tag })
: updateSeverity === 'deprecated'
? t('settings.advanced.about.deprecated_update', 'Update required: {version}', { version: latestRelease.tag })
: t('settings.advanced.about.update_available', 'Update: {version}', { version: latestRelease.tag })}
{supportsSideloadUpdates
? ''
: ` · ${t('settings.advanced.about.update_store_hint', 'available in TestFlight / App Store')}`}
</Text>
</Pressable>
)}
</View>
<Pressable
style={styles.ghLink}
accessibilityRole="link"
onPress={() => Linking.openURL('https://github.com/bulwarkmail/native')}
>
<Text style={styles.ghText}>GitHub</Text>
<ExternalLink size={12} color={c.mutedForeground} />
</Pressable>
</View>
</View>
<SettingsSection
title={t('settings.offline.title', 'Offline mail')}
description={t(
'settings.offline.description',
'Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand.',
)}
>
<SettingItem
label={t('settings.offline.enabled', 'Cache recent mail')}
description={t('settings.offline.enabled_desc', 'When on, the app keeps message bodies for the selected window on this device.')}
>
<ToggleSwitch
checked={offlineEnabled}
onChange={(v) => updateSetting('offlineCacheEnabled', v)}
/>
</SettingItem>
<SettingItem
label={t('settings.offline.window', 'Window')}
description={t('settings.offline.window_desc', 'How far back to cache, measured by message receipt date.')}
>
<Select
value={String(offlineDays)}
onChange={(v) => updateSetting('offlineCacheDays', Number(v))}
options={dayOptions}
/>
</SettingItem>
<SettingItem
label={t('settings.offline.max_size', 'Maximum size')}
description={t('settings.offline.max_size_desc', 'Oldest messages are removed when the cache grows past this.')}
>
<Select
value={String(offlineMaxMB)}
onChange={(v) => updateSetting('offlineCacheMaxMB', Number(v))}
options={maxMbOptions}
/>
</SettingItem>
<View style={styles.cacheStatsBox}>
<View style={styles.cacheStatsHeader}>
<CloudDownload size={16} color={c.textSecondary} />
<Text style={styles.cacheStatsTitle}>
{cacheCount === 0
? t('settings.offline.nothing_cached', 'Nothing cached yet')
: `${t('settings.offline.cached_count', '{count, plural, one {# message} other {# messages}}', { count: cacheCount })} • ${formatBytes(cacheBytes)}`}
</Text>
</View>
<Text style={styles.cacheStatsSub}>
{sync.phase === 'fetching'
? t('settings.offline.downloading', 'Downloading {completed}/{total}…', { completed: sync.completed, total: sync.total })
: sync.phase === 'scanning'
? t('settings.offline.scanning', 'Scanning recent mail…')
: sync.phase === 'error'
? t('settings.offline.last_sync_failed', 'Last sync failed: {message}', { message: sync.message ?? t('identities.validation_errors.unknown_error', 'Unknown error') })
: t('settings.offline.last_sync', 'Last sync {when}', { when: formatRelativeTime(sync.finishedAt) })}
</Text>
{queuedChanges > 0 && (
<Text style={styles.cacheStatsSub}>
{t('settings.offline.queued_changes', '{count, plural, one {# change} other {# changes}} waiting to sync', { count: queuedChanges })}
</Text>
)}
{syncBusy && sync.total > 0 && (
<View style={styles.progressTrack} accessibilityRole="progressbar">
<View
style={[
styles.progressFill,
{ width: `${Math.min(100, Math.round((sync.completed / sync.total) * 100))}%` },
]}
/>
</View>
)}
<View style={styles.cacheActions}>
<Button
variant="outline"
size="sm"
onPress={handleSyncNow}
disabled={!offlineEnabled || syncBusy}
icon={syncBusy ? <ActivityIndicator size="small" color={c.text} /> : undefined}
>
{syncBusy ? t('settings.offline.syncing', 'Syncing…') : t('settings.offline.sync_now', 'Sync now')}
</Button>
<Button
variant="outline"
size="sm"
onPress={handleClearCache}
disabled={cacheCount === 0 || syncBusy}
>
{t('settings.offline.clear_cache', 'Clear cache')}
</Button>
</View>
</View>
</SettingsSection>
<SettingsSection
title={t('settings.advanced.title', 'Advanced')}
description={t('settings.advanced.description', 'Advanced options and developer settings')}
>
<SettingItem
label={t('settings.advanced.debug_mode.label', 'Debug Mode')}
description={t('settings.advanced.debug_mode.description', 'Enable detailed logging for troubleshooting')}
>
<ToggleSwitch checked={debugMode} onChange={(v) => updateSetting('debugMode', v)} />
</SettingItem>
{debugMode && (
<View style={styles.debugCategoriesBox}>
<Text style={styles.debugCategoriesHint}>
{t(
'settings.advanced.debug_categories.description',
"Select which categories to log. Disable categories you don't need to reduce console noise.",
)}
</Text>
{ALL_DEBUG_CATEGORIES.map((cat: DebugCategory) => (
<SettingItem
key={cat}
label={t(`settings.advanced.debug_categories.${cat}`, cat)}
description={t(`settings.advanced.debug_categories.${cat}_description`, '')}
>
<ToggleSwitch
checked={debugCategories[cat] !== false}
onChange={(v) => updateSetting('debugCategories', { ...debugCategories, [cat]: v })}
/>
</SettingItem>
))}
</View>
)}
<SettingItem
label={t('settings.advanced.sender_favicons.label', 'Sender Favicons')}
description={t('settings.advanced.sender_favicons.description', 'Show website icons as profile pictures for business senders')}
>
<ToggleSwitch checked={senderFavicons} onChange={setSenderFaviconsStore} />
</SettingItem>
<SettingItem
label={t('settings.advanced.refresh_cache.label', 'Refresh cached data')}
description={t(
'settings.advanced.refresh_cache.description',
'Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.',
)}
>
<Button variant="outline" size="sm" onPress={() => void handleRefreshCache()} loading={refreshing}>
{t('settings.advanced.refresh_cache.button', 'Refresh')}
</Button>
</SettingItem>
<SettingItem
label={t('settings.advanced.export_settings.label', 'Export Settings')}
description={t('settings.advanced.export_settings.description', 'Download your settings as JSON')}
>
<Button variant="outline" size="sm" onPress={() => void handleExport()}>
{t('settings.advanced.export_settings.button', 'Export')}
</Button>
</SettingItem>
<SettingItem
label={t('settings.advanced.import_settings.label', 'Import Settings')}
description={t('settings.advanced.import_settings.description', 'Upload settings from JSON file')}
>
<Button variant="outline" size="sm" onPress={() => void handleImport()}>
{t('settings.advanced.import_settings.button', 'Import')}
</Button>
</SettingItem>
<SettingItem
label={t('settings.advanced.reset_settings.label', 'Reset Settings')}
description={t('settings.advanced.reset_settings.description', 'Restore all settings to default values')}
>
<Button variant="outline" size="sm" onPress={() => setConfirmReset(true)}>
{t('settings.advanced.reset_settings.button', 'Reset to Defaults')}
</Button>
</SettingItem>
</SettingsSection>
<Dialog
visible={confirmReset}
variant="destructive"
title={t('settings.advanced.reset_settings.label', 'Reset Settings')}
message={t('settings.reset_confirm', 'Are you sure you want to reset all settings to defaults?')}
confirmText={t('settings.advanced.reset_settings.button', 'Reset to Defaults')}
onCancel={() => setConfirmReset(false)}
onConfirm={handleReset}
/>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
aboutBox: {
padding: spacing.xl,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.card,
},
aboutRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.lg,
},
logo: {
width: 48,
height: 48,
borderRadius: radius.md,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
},
logoText: { fontSize: 24, fontWeight: '700', color: c.primaryForeground },
aboutTitle: { ...typography.bodyMedium, color: c.text },
aboutVersion: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
aboutCommit: { color: c.mutedForeground, opacity: 0.6 },
updatePill: {
alignSelf: 'flex-start',
marginTop: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
updatePillSecurity: { backgroundColor: c.errorBg },
updatePillText: { ...typography.caption, color: c.primary },
updatePillTextSecurity: { color: c.error },
ghLink: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
ghText: { ...typography.caption, color: c.mutedForeground },
debugCategoriesBox: {
marginStart: spacing.lg,
paddingStart: spacing.lg,
borderStartWidth: 2,
borderStartColor: c.muted,
gap: 4,
},
debugCategoriesHint: {
...typography.caption,
color: c.mutedForeground,
marginBottom: spacing.sm,
},
cacheStatsBox: {
marginTop: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.muted,
gap: spacing.sm,
},
cacheStatsHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
cacheStatsTitle: { ...typography.bodyMedium, color: c.text, flex: 1 },
cacheStatsSub: { ...typography.caption, color: c.mutedForeground },
progressTrack: {
height: 4,
borderRadius: radius.full,
backgroundColor: c.border,
overflow: 'hidden',
},
progressFill: { height: '100%', backgroundColor: c.primary },
cacheActions: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
});
}
+202
View File
@@ -0,0 +1,202 @@
import React, { useEffect } from 'react';
import { View, Text, Image, StyleSheet, Pressable, Linking, Platform } from 'react-native';
import Constants from 'expo-constants';
import { ExternalLink } from '../icons';
import { SettingsSection, SettingItem } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors, useResolvedTheme } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { useAuthStore } from '../../stores/auth-store';
import { useAccountStore } from '../../stores/account-store';
import { useUpdatesStore } from '../../stores/updates-store';
import { supportsSideloadUpdates } from '../../lib/platform-capabilities';
import { buildIssueUrl, sourceCodeUrl, updateSourceUrl } from '../../lib/about-links';
const LOGO_WHITE = require('../../../assets/logos/Bulwark Logo White.png');
const LOGO_COLOR = require('../../../assets/logos/Bulwark Logo Color.png');
const extra = (Constants.expoConfig?.extra ?? {}) as { commit?: string; sourceCodeUrl?: string };
export const APP_NAME = Constants.expoConfig?.name ?? 'Bulwark';
export const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
export const GIT_COMMIT = extra.commit ?? 'dev';
const SOURCE_URL = sourceCodeUrl(extra.sourceCodeUrl);
function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}
interface AboutSettingsProps {
/** Opens a page below About: Updates, the licence list, Privacy. */
onOpen?: (page: 'updates' | 'licences' | 'privacy') => void;
}
export function AboutSettings({ onOpen }: AboutSettingsProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const scheme = useResolvedTheme();
const t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale);
const authUsername = useAuthStore((s) => s.username);
const authServerUrl = useAuthStore((s) => s.serverUrl);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const account = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
const serverUrl = account?.serverUrl || authServerUrl || '';
const signedInAs = account?.email || account?.username || authUsername || '';
const hydrated = useUpdatesStore((s) => s.hydrated);
const hydrate = useUpdatesStore((s) => s.hydrate);
const checking = useUpdatesStore((s) => s.checking);
const checkNow = useUpdatesStore((s) => s.checkNow);
const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt);
const latest = useUpdatesStore((s) => s.cachedLatest);
const updateAvailable = useUpdatesStore((s) => s.hasUpdate());
const severity = latest?.severity ?? 'normal';
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const updateLine = updateAvailable && latest
? severity === 'security'
? t('settings.advanced.about.security_update', 'Security update: {version}', { version: latest.tag })
: severity === 'deprecated'
? t('settings.advanced.about.deprecated_update', 'Update required: {version}', { version: latest.tag })
: t('settings.advanced.about.update_available', 'Update: {version}', { version: latest.tag })
: lastCheckedAt > 0
? t('settings.about.up_to_date', 'Up to date')
: null;
const openUpdate = () => {
if (supportsSideloadUpdates && onOpen) onOpen('updates');
else void Linking.openURL(updateSourceUrl(Platform.OS, latest?.htmlUrl));
};
const lastChecked = lastCheckedAt > 0
? t('settings.about.checked_at', 'Checked {when}', {
when: new Date(lastCheckedAt).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }),
})
: t('settings.about.never_checked', 'Not checked yet');
const reportProblem = () => {
const constants = Platform.constants as { Release?: string; Model?: string };
void Linking.openURL(buildIssueUrl(
{ version: APP_VERSION, commit: GIT_COMMIT },
{
os: Platform.OS,
osVersion: Platform.Version,
release: constants.Release,
model: constants.Model,
},
));
};
return (
<View style={styles.container}>
<View style={styles.card}>
<Image
source={scheme === 'dark' ? LOGO_WHITE : LOGO_COLOR}
style={styles.logo}
resizeMode="contain"
accessibilityIgnoresInvertColors
accessible={false}
/>
<View style={styles.cardText}>
<Text style={styles.appName}>{APP_NAME}</Text>
<Text style={styles.version}>v{APP_VERSION} ({GIT_COMMIT})</Text>
{updateLine && (
updateAvailable ? (
<Pressable onPress={openUpdate} accessibilityRole="link" hitSlop={{ top: 12, bottom: 12 }}>
<Text style={[styles.updateLine, severity === 'normal' ? styles.updateLineNew : styles.updateLineUrgent]}>
{updateLine}
</Text>
</Pressable>
) : (
<Text style={[styles.updateLine, styles.updateLineOk]}>{updateLine}</Text>
)
)}
</View>
<Pressable
onPress={() => void Linking.openURL(SOURCE_URL)}
accessibilityRole="link"
style={styles.sourceLink}
>
<Text style={styles.sourceText}>{t('settings.about.source_code', 'Source code')}</Text>
<ExternalLink size={14} color={c.mutedForeground} />
</Pressable>
</View>
<SettingsSection
title={t('settings.about.this_device', 'This device')}
description={t('settings.about.this_device_desc', 'What the app is connected to and how to get help.')}
>
<SettingItem
label={t('settings.about.server', 'Server')}
description={serverUrl
? t('settings.about.server_value', '{host} · signed in as {user}', { host: hostOf(serverUrl), user: signedInAs })
: undefined}
/>
{supportsSideloadUpdates && (
<SettingItem label={t('settings.about.updates', 'Updates')} description={lastChecked}>
<Button variant="outline" size="sm" loading={checking} onPress={() => void checkNow({ force: true })}>
{t('settings.about.check_now', 'Check now')}
</Button>
</SettingItem>
)}
<SettingItem
label={t('settings.about.licences', 'Open-source licences')}
description={t('settings.about.licences_desc', 'AGPL-3.0 for the app, and the libraries it uses')}
onPress={() => onOpen?.('licences')}
/>
<SettingItem
label={t('settings.about.privacy', 'Privacy')}
description={t('settings.about.privacy_desc', 'What stays on this phone and what goes to your server')}
onPress={() => onOpen?.('privacy')}
/>
<SettingItem
label={t('settings.about.report_problem', 'Report a problem')}
description={t('settings.about.report_problem_desc', 'Opens an issue on GitHub with the version filled in')}
onPress={reportProblem}
link="external"
noBorder
/>
</SettingsSection>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxl },
card: {
flexDirection: 'row',
alignItems: 'center',
gap: 14,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.card,
},
logo: { width: 48, height: 48 },
cardText: { flex: 1, minWidth: 0, gap: 3 },
appName: { fontSize: 16, fontWeight: '600', lineHeight: 22, color: c.text },
version: { ...typography.caption, fontSize: 13, lineHeight: 18, color: c.mutedForeground },
updateLine: { ...typography.caption },
updateLineOk: { color: c.success },
updateLineNew: { color: c.textLink },
updateLineUrgent: { color: c.error },
sourceLink: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
minHeight: 48,
paddingHorizontal: 4,
},
sourceText: { fontSize: 13, color: c.mutedForeground },
});
}
@@ -4,7 +4,7 @@ import DateTimePicker, { type DateTimePickerEvent } from '@react-native-communit
import {
Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2,
Plus, Shield, Terminal, Check, ExternalLink, Calendar, X,
} from 'lucide-react-native';
} from '../icons';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Input from '../Input';
import Button from '../Button';
+3 -3
View File
@@ -2,10 +2,10 @@ import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Alert } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ArrowDown, ArrowUp, Check, ChevronRight, Plus, Star, Trash2, Users } from 'lucide-react-native';
import { ArrowDown, ArrowUp, Check, ChevronRight, Plus, Star, Trash2, Users } from '../icons';
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 },
});
+11 -1
View File
@@ -93,6 +93,8 @@ export function AppearanceSettings() {
const setShowToolbarLabels = useSettingsStore((s) => s.setShowToolbarLabels);
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
const setAnimationsEnabled = useSettingsStore((s) => s.setAnimationsEnabled);
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const setSenderFavicons = useSettingsStore((s) => s.setSenderFavicons);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
@@ -103,7 +105,7 @@ export function AppearanceSettings() {
return (
<SettingsSection
title={t('settings.appearance.title', "Appearance")}
description={t('settings.appearance.description', "Customize the look and feel of your webmail")}
description={t('settings.appearance.description_mobile', 'Customize how the app looks')}
>
<SettingItem label={t('settings.appearance.theme.label', "Theme")} description={t('settings.appearance.theme.description', "Choose your preferred color scheme")}>
<RadioGroup
@@ -152,6 +154,14 @@ export function AppearanceSettings() {
<SettingItem label={t('settings.appearance.animations.label', "Enable Animations")} description={t('settings.appearance.animations.description', "Show smooth transitions and effects")}>
<ToggleSwitch checked={animationsEnabled} onChange={setAnimationsEnabled} />
</SettingItem>
<SettingItem
label={t('settings.advanced.sender_favicons.label', 'Sender Favicons')}
description={t('settings.advanced.sender_favicons.description', 'Show website icons as profile pictures for business senders')}
noBorder
>
<ToggleSwitch checked={senderFavicons} onChange={setSenderFavicons} />
</SettingItem>
</SettingsSection>
);
}
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { X, Plus } from 'lucide-react-native';
import { X, Plus } from '../icons';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
+1 -1
View File
@@ -4,7 +4,7 @@ import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import {
Download, Upload, Plus, Pencil, Trash2, Check, X, BookUser, Star,
} from 'lucide-react-native';
} from '../icons';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import Dialog from '../Dialog';
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import {
View, Text, StyleSheet, Pressable, TextInput, FlatList, Modal, Alert,
} from 'react-native';
import { ChevronRight, X, Plus, Trash2, BookUser } from 'lucide-react-native';
import { ChevronRight, X, Plus, Trash2, BookUser } from '../icons';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -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"
@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { RotateCcw } from 'lucide-react-native';
import { RotateCcw } from '../icons';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import Input from '../Input';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -179,7 +179,7 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.muted,
gap: 2,
},
previewLabel: { fontSize: 11, fontWeight: '600', color: c.mutedForeground, textTransform: 'uppercase' },
previewLabel: { fontSize: 12, fontWeight: '500', color: c.mutedForeground },
previewValue: { ...typography.captionMedium, color: c.text },
tokenWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: spacing.md },
tokenPill: {
+1 -1
View File
@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import {
Folder, FileText, FileCode, FileAudio, File, Image as ImageIcon,
} from 'lucide-react-native';
} from '../icons';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
+8 -7
View File
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'rea
import {
Plus, X, Code, AlertTriangle, Filter, RotateCcw, Lock,
ChevronUp, ChevronDown, Palmtree, ChevronRight,
} from 'lucide-react-native';
} from '../icons';
import { SettingsSection, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -21,6 +21,7 @@ import { FilterRuleModal } from '../filters/FilterRuleModal';
import { SieveEditorSheet } from '../filters/SieveEditorSheet';
import type { FilterRule } from '../../lib/sieve/types';
import { formatConditionValue, summarizeRule } from '../../lib/sieve/condition-value';
import Badge from '../Badge';
type Translate = (key: string, fallback?: string) => string;
@@ -366,7 +367,7 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
<View style={{ flex: 1, minWidth: 0 }}>
<View style={styles.ruleNameRow}>
<Text style={styles.ruleName} numberOfLines={1}>{rule.name}</Text>
<View style={styles.originBadge}><Text style={styles.originBadgeText}>{label}</Text></View>
<Badge>{label}</Badge>
</View>
{hasStructured ? (
expandedView ? (
@@ -512,11 +513,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 },
@@ -569,8 +572,6 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.muted,
borderRadius: radius.xs,
},
originBadge: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full, backgroundColor: c.muted },
originBadgeText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
reorderCol: { alignItems: 'center' },
reorderBtn: { padding: 2 },
+9 -13
View File
@@ -13,7 +13,7 @@ import {
import {
Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive, Flag, Star, Mails,
StickyNote, Clock, AlarmClock, Users, Plus, Pencil, Trash2, X,
} from 'lucide-react-native';
} from '../icons';
import { SettingsSection, Select } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -25,6 +25,9 @@ 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';
import Badge from '../Badge';
const ROLE_ICON: Record<string, any> = {
inbox: Inbox, drafts: FileText, sent: Send, trash: Trash,
@@ -63,6 +66,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);
@@ -271,11 +276,7 @@ export function FolderSettings() {
<View style={styles.folderLeft}>
<Icon size={16} color={mb.role ? c.primary : c.mutedForeground} />
<Text style={styles.folderName} numberOfLines={1}>{localizeMailboxName(mb.role, mb.name, t)}</Text>
{mb.role && (
<View style={styles.rolePill}>
<Text style={styles.rolePillText}>{mb.role}</Text>
</View>
)}
{mb.role && <Badge>{mb.role}</Badge>}
</View>
<View style={styles.folderRight}>
{mb.unreadEmails > 0 && (
@@ -297,8 +298,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}>
@@ -412,11 +413,6 @@ function makeStyles(c: ThemePalette) {
folderRowPressed: { backgroundColor: c.muted },
folderLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flex: 1 },
folderName: { ...typography.body, color: c.text, flexShrink: 1 },
rolePill: {
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
rolePillText: { fontSize: 10, fontWeight: '500', color: c.primary },
folderRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
unreadBadge: {
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
+9 -11
View File
@@ -10,7 +10,7 @@ import {
TextInput,
View,
} from 'react-native';
import { Plus, Star, Trash2, X } from 'lucide-react-native';
import { Plus, Star, Trash2, X } from '../icons';
import { SettingsSection } from './settings-section';
import Button from '../Button';
import { typography, spacing, radius, type ThemePalette } from '../../theme/tokens';
@@ -27,6 +27,9 @@ 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';
import Badge from '../Badge';
type DraftIdentity = {
id: string;
@@ -92,6 +95,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);
@@ -276,9 +281,7 @@ export function IdentitySettings() {
</Pressable>
)}
{!identity.mayDelete ? (
<View style={styles.badge}>
<Text style={styles.badgeText}>{t('settings.identities.primary', "primary")}</Text>
</View>
<Badge>{t('settings.identities.primary', "primary")}</Badge>
) : (
<Pressable
onPress={() => confirmDelete(identity)}
@@ -298,8 +301,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}>
@@ -419,11 +422,6 @@ function makeStyles(c: ThemePalette) {
identityName: { ...typography.bodyMedium, color: c.text },
identityEmail: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
identityDelete: { padding: 6 },
badge: {
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
badgeText: { fontSize: 10, fontWeight: '500', color: c.primary },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
modalSheet: {
+14 -4
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput, Alert, ActivityIndicator } from 'react-native';
import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from 'lucide-react-native';
import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from '../icons';
import { SettingsSection } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -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>
@@ -308,6 +317,7 @@ function makeStyles(c: ThemePalette) {
},
bottomActions: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
paddingTop: spacing.sm,
},
+1 -1
View File
@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Check } from 'lucide-react-native';
import { Check } from '../icons';
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
+1 -1
View File
@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ArrowRight, ArrowLeft, MoveVertical, ArrowDownWideNarrow } from 'lucide-react-native';
import { ArrowRight, ArrowLeft, MoveVertical, ArrowDownWideNarrow } from '../icons';
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -0,0 +1,47 @@
import React from 'react';
import { View, Linking } from 'react-native';
import { SettingsSection, SettingItem } from './settings-section';
import { useLocaleStore } from '../../stores/locale-store';
import { NATIVE_REPO_URL } from '../../lib/about-links';
import licences from '../../lib/licences.json';
import { APP_NAME } from './AboutSettings';
/** Settings → About → Open-source licences. The list is generated by scripts/generate-licences.mjs. */
export function LicencesSettings() {
const t = useLocaleStore((s) => s.t);
return (
<View style={{ gap: 24 }}>
<SettingsSection
title={APP_NAME}
description={t(
'settings.licences.app_desc',
'The app is free software under the {licence}. You can use, study, share and change it.',
{ licence: 'GNU Affero General Public License v3.0' },
)}
>
<SettingItem
label={licences.app.license}
description={t('settings.licences.read_licence', 'Read the full licence text')}
onPress={() => void Linking.openURL(`${NATIVE_REPO_URL}/blob/main/LICENSE`)}
link="external"
noBorder
/>
</SettingsSection>
<SettingsSection
title={t('settings.licences.libraries', 'Libraries')}
description={t('settings.licences.libraries_desc', 'Open-source code the app is built with.')}
>
{licences.libraries.map((lib, i) => (
<SettingItem
key={lib.name}
label={lib.name}
description={lib.version ? `${lib.license} · ${lib.version}` : lib.license}
onPress={lib.url ? () => void Linking.openURL(lib.url) : undefined}
link="external"
noBorder={i === licences.libraries.length - 1}
/>
))}
</SettingsSection>
</View>
);
}
@@ -7,7 +7,7 @@ import {
TextInput,
View,
} from 'react-native';
import { CheckCircle2, RefreshCw, XCircle } from 'lucide-react-native';
import { CheckCircle2, RefreshCw, XCircle } from '../icons';
import Button from '../Button';
import Dialog from '../Dialog';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
@@ -1,55 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Puzzle } from 'lucide-react-native';
import { SettingsSection } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
/**
* The webmail's plugin runtime (sandboxed iframes, marketplace, signing) has
* no counterpart in the native app, so this pane only explains where plugins
* live. The tab is hidden from the settings list; the component stays so a
* deep link to `settings/plugins` still renders something sensible.
*/
export function PluginsSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
return (
<SettingsSection
title={t('settings.tabs.plugins', 'Plugins')}
description={t(
'settings.plugins.mobile_description',
'Plugins run inside the webmail and extend it in the browser.',
)}
>
<View style={styles.box}>
<Puzzle size={20} color={c.mutedForeground} />
<Text style={styles.text}>
{t(
'settings.plugins.mobile_explainer',
'The mobile app has no plugin runtime. Install and manage plugins from the webmail under Settings → Plugins; themes and filters they provide are not applied here.',
)}
</Text>
</View>
</SettingsSection>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
box: {
flexDirection: 'row',
gap: spacing.md,
alignItems: 'flex-start',
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.muted,
},
text: { ...typography.body, color: c.mutedForeground, flex: 1 },
});
}
@@ -0,0 +1,81 @@
import React from 'react';
import { View, Text, StyleSheet, Platform } from 'react-native';
import { SettingsSection } from './settings-section';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { DEFAULT_RELAY_BASE_URL } from '../../lib/push-notifications';
import { supportsSideloadUpdates } from '../../lib/platform-capabilities';
function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}
/** Settings → About → Privacy: where the app keeps and sends data. */
export function PrivacySettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const paragraph = (text: string) => <Text style={styles.paragraph}>{text}</Text>;
return (
<View style={styles.container}>
<SettingsSection title={t('settings.privacy.phone_title', 'On this phone')}>
{paragraph(t(
'settings.privacy.phone_secure',
'Your sign-in details are kept in the system\'s secure storage. Settings, the offline mail cache and changes waiting to sync are kept in the app\'s own storage.',
))}
{Platform.OS === 'android' && paragraph(t(
'settings.privacy.phone_backup',
'None of it goes into Android\'s cloud backup.',
))}
</SettingsSection>
<SettingsSection title={t('settings.privacy.server_title', 'Your mail server')}>
{paragraph(t(
'settings.privacy.server',
'Mail, calendars, contacts and files are loaded from your server and saved back to it. Apart from the connections below, the app talks only to your server.',
))}
</SettingsSection>
<SettingsSection title={t('settings.privacy.push_title', 'Push notifications')}>
{paragraph(t(
'settings.privacy.push',
'When notifications are on, your server tells the push relay ({relay}) that something changed, and the relay wakes the app through {service}. The relay sees a device token and a change marker, never your mail. You can use your own relay under Notifications.',
{
relay: hostOf(DEFAULT_RELAY_BASE_URL),
service: Platform.OS === 'ios'
? t('settings.privacy.service_apple', 'Apple\'s push service')
: t('settings.privacy.service_google', 'Google\'s push service'),
},
))}
</SettingsSection>
<SettingsSection title={t('settings.privacy.other_title', 'Other connections')}>
{paragraph(t(
'settings.privacy.favicons',
'Sender icons for business mail come from DuckDuckGo\'s icon service, which sees the sender\'s domain. Turn them off under Appearance → Sender favicons.',
))}
{paragraph(supportsSideloadUpdates
? t('settings.privacy.updates_android', 'The app asks GitHub for the latest release to tell you about updates. Turn that off under Updates.')
: t('settings.privacy.updates', 'The app asks GitHub for the latest release to tell you about updates.'))}
{paragraph(t(
'settings.privacy.no_tracking',
'The app has no analytics and no ads.',
))}
</SettingsSection>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxl },
paragraph: { ...typography.body, color: c.text, paddingVertical: spacing.xs },
});
}

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