mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b50436b1ba | ||
|
|
11e74042e2 | ||
|
|
95580b84ea | ||
|
|
9a15334bef | ||
|
|
3aca2d80c0 | ||
|
|
7c534301ee | ||
|
|
14dc5ff2d4 | ||
|
|
14ba28c79c | ||
|
|
598adc154d | ||
|
|
3c0af52c4e | ||
|
|
7108431dbb | ||
|
|
744b17908d | ||
|
|
c7edae9ed3 | ||
|
|
c07b39a070 | ||
|
|
7e5e296271 | ||
|
|
ad6dff79ba | ||
|
|
a4ca72fb3d | ||
|
|
107eb807e3 | ||
|
|
e731357b89 | ||
|
|
9cf1d3e849 | ||
|
|
d3dcc4e830 | ||
|
|
e0d2b190d3 | ||
|
|
b048eaca03 | ||
|
|
6f515e15f7 | ||
|
|
0a176ab51c | ||
|
|
75299244cf | ||
|
|
6aaa479875 | ||
|
|
124c0258ad | ||
|
|
ccffd6b10d | ||
|
|
0d96d8d044 | ||
|
|
95c31631fb | ||
|
|
cf7275c9c3 | ||
|
|
e452f72a8a | ||
|
|
776447dd41 | ||
|
|
52eb18dc33 | ||
|
|
33dc3cd0dc | ||
|
|
72b4f4abac | ||
|
|
de1b955bfd | ||
|
|
c8434b0610 | ||
|
|
e19229a5c6 | ||
|
|
d7cee7ca78 | ||
|
|
b59d00f0b7 | ||
|
|
975ed08c82 | ||
|
|
76170648f5 | ||
|
|
509583e729 | ||
|
|
7b93265b4f | ||
|
|
b5c4d62bde | ||
|
|
c6cb44db61 | ||
|
|
0db4bf00aa | ||
|
|
e94b782f8e | ||
|
|
ae1ef5ba5e | ||
|
|
f891bf3abb | ||
|
|
af55e0acef | ||
|
|
1ebc5c66f7 | ||
|
|
0b3710265b | ||
|
|
56e1593646 | ||
|
|
e7857ef806 | ||
|
|
b3290c3202 | ||
|
|
f231d1c5c6 | ||
|
|
984c359bb4 | ||
|
|
50ab7145c4 | ||
|
|
8f68d24bdb | ||
|
|
f22e851c82 | ||
|
|
d4982f6932 | ||
|
|
275d674577 | ||
|
|
0460da51e5 | ||
|
|
90170ea0e7 | ||
|
|
cbc41c963b | ||
|
|
63eefd7700 | ||
|
|
d97571cf0f | ||
|
|
8403843e76 | ||
|
|
756ceaa436 | ||
|
|
99de74de93 | ||
|
|
46d30169b2 | ||
|
|
512a28c494 | ||
|
|
29c172f2af | ||
|
|
9e463d3a66 | ||
|
|
ab712f11ba | ||
|
|
053fde8d56 | ||
|
|
250c0a9c26 | ||
|
|
d62805f805 | ||
|
|
b9e6ba417d | ||
|
|
0087e4c8ff | ||
|
|
2470e99dc8 | ||
|
|
8ab82cdeb7 | ||
|
|
64a62b6306 | ||
|
|
f4ff07da7a | ||
|
|
2288d28e46 | ||
|
|
295e292e47 | ||
|
|
802474d3f2 | ||
|
|
bfa02f1177 | ||
|
|
4b29cdfd3c | ||
|
|
2a47913d70 | ||
|
|
8ce53816af | ||
|
|
767d4b9f9f | ||
|
|
e19885dc72 | ||
|
|
4816f0e08b | ||
|
|
ef1d4bc387 | ||
|
|
61690603a2 | ||
|
|
765ff26aa5 | ||
|
|
b832ac0c49 | ||
|
|
f53cee7ec9 | ||
|
|
21d469f7e8 | ||
|
|
c1e0cb36c9 | ||
|
|
d8879617ed | ||
|
|
7b62755aee | ||
|
|
96e1cc1c3f | ||
|
|
ec137e931b | ||
|
|
cf78b07939 | ||
|
|
5237d9004d | ||
|
|
be15af5d4c | ||
|
|
90fe43882a | ||
|
|
465eeffd1e | ||
|
|
200404483c | ||
|
|
28f6787279 | ||
|
|
89f0524ad4 | ||
|
|
172110f7b0 | ||
|
|
aa612e6f0e | ||
|
|
76180b332d | ||
|
|
f5c491f6d7 | ||
|
|
23af324d79 | ||
|
|
761c35918f | ||
|
|
86e33b9afe | ||
|
|
bd1b2f3fd3 | ||
|
|
7c4b584d9c | ||
|
|
e35cd82f5f |
@@ -25,6 +25,9 @@ jobs:
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.export.outputs.version }}
|
||||
uploaded: ${{ steps.upload.outputs.done }}
|
||||
env:
|
||||
EXPO_NO_TELEMETRY: "1"
|
||||
# App Store Connect refuses a CFBundleVersion it has already seen for this
|
||||
@@ -254,6 +257,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to TestFlight
|
||||
id: upload
|
||||
# The IPA is not attached to the GitHub release: without an Apple device
|
||||
# provisioned for it, it is not installable, and TestFlight is the only
|
||||
# distribution channel for it.
|
||||
@@ -271,4 +275,35 @@ jobs:
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
xcrun altool --upload-app --type ios --file "$IPA" \
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
echo "done=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight."
|
||||
|
||||
# Puts the upload on the public TestFlight link (README): waits for App Store
|
||||
# Connect to process it, adds it to the external group and submits it for
|
||||
# beta review. On Linux, because most of this job is waiting.
|
||||
public-testflight:
|
||||
needs: build
|
||||
if: needs.build.outputs.uploaded == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install the App Store Connect API key
|
||||
env:
|
||||
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
run: |
|
||||
mkdir -p ~/private_keys
|
||||
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
|
||||
- name: Add the build to the public group and submit it for beta review
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||
run: node scripts/testflight-public.mjs "${{ needs.build.outputs.version }}" "${{ github.run_number }}"
|
||||
|
||||
@@ -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';
|
||||
@@ -119,9 +120,11 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi
|
||||
navigationRef.navigate('EmailThread', {
|
||||
emailId: payload.emailId,
|
||||
threadId: payload.threadId,
|
||||
emailIds: [payload.emailId],
|
||||
subject: payload.subject,
|
||||
// A group mailbox's message lives under another JMAP account (#839).
|
||||
jmapAccountId: notificationTapJmapAccountId(payload),
|
||||
...(payload.action === 'reply' ? { action: 'reply' as const } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -241,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 />
|
||||
@@ -253,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,
|
||||
@@ -289,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"
|
||||
@@ -331,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} />,
|
||||
@@ -400,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) {
|
||||
@@ -830,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
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
|
||||
React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app.
|
||||
|
||||
## Try it
|
||||
|
||||
- **iPhone and iPad:** join the beta on TestFlight: https://testflight.apple.com/join/rumcjNSp. A new release shows up there once Apple has reviewed it, usually within a day.
|
||||
- **Android:** download the APK from the [latest release](https://github.com/bulwarkmail/native/releases/latest).
|
||||
|
||||
## What works today
|
||||
|
||||
- Sign in to any JMAP server (e.g. Stalwart)
|
||||
@@ -30,7 +35,7 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
|
||||
- iOS builds, but push notifications, client certificates and syncing with the phone's contacts and calendars are Android-only so far
|
||||
- S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations)
|
||||
- Calendar editing is partial; contacts editing is basic
|
||||
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
|
||||
- No Play Store or App Store listing yet: Android is a sideloaded APK, iOS a TestFlight beta (see [Try it](#try-it))
|
||||
|
||||
## Run locally
|
||||
|
||||
|
||||
@@ -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"/>
|
||||
@@ -76,6 +76,7 @@
|
||||
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
|
||||
<service android:name=".BulwarkWidgetRefreshService" android:exported="false"/>
|
||||
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
|
||||
<receiver android:name=".BulwarkNotificationActionReceiver" android:exported="false"/>
|
||||
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
@@ -72,14 +73,20 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle")
|
||||
?: accountId ?: "Bulwark Mail"
|
||||
|
||||
val preview = options.takeIf { it.hasKey("preview") }?.getString("preview")
|
||||
val markReadLabel = options.takeIf { it.hasKey("markReadLabel") }?.getString("markReadLabel") ?: "Mark as read"
|
||||
val deleteLabel = options.takeIf { it.hasKey("deleteLabel") }?.getString("deleteLabel") ?: "Delete"
|
||||
val replyLabel = options.takeIf { it.hasKey("replyLabel") }?.getString("replyLabel") ?: "Reply"
|
||||
|
||||
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||
// block waiting for the favicon request.
|
||||
thread(name = "bulwark-notification") {
|
||||
val largeIcon = iconUrl?.let { fetchBitmap(it) }
|
||||
?: makeLetterAvatar(initials, bgColorHex)
|
||||
postNotification(
|
||||
notificationId, title, body, largeIcon, bgColorHex,
|
||||
notificationId, title, body, preview, largeIcon, bgColorHex,
|
||||
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
|
||||
groupTitle, markReadLabel, deleteLabel, replyLabel,
|
||||
)
|
||||
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
|
||||
promise.resolve(null)
|
||||
@@ -102,6 +109,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
notificationId: String,
|
||||
title: String,
|
||||
body: String,
|
||||
preview: String?,
|
||||
largeIcon: Bitmap,
|
||||
colorHex: String,
|
||||
emailId: String?,
|
||||
@@ -110,10 +118,15 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
accountId: String?,
|
||||
jmapAccountId: String?,
|
||||
groupKey: String?,
|
||||
groupTitle: String?,
|
||||
markReadLabel: String,
|
||||
deleteLabel: String,
|
||||
replyLabel: String,
|
||||
) {
|
||||
val ctx = reactApplicationContext
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-notification://$notificationId")
|
||||
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
@@ -127,12 +140,27 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val bigText = buildString {
|
||||
if (!subject.isNullOrBlank()) append(subject)
|
||||
if (!preview.isNullOrBlank()) {
|
||||
if (isNotEmpty()) append("\n")
|
||||
append(preview)
|
||||
}
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setLargeIcon(largeIcon)
|
||||
.setContentTitle(title)
|
||||
// body is the subject with the "(No subject)" fallback; an empty
|
||||
// subject is "" here, not null, so `subject ?: ...` showed a blank line.
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
.bigText(if (bigText.isNotBlank()) bigText else body)
|
||||
.setBigContentTitle(title)
|
||||
.setSummaryText(groupTitle)
|
||||
)
|
||||
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
@@ -142,6 +170,72 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
}
|
||||
|
||||
if (emailId != null && accountId != null) {
|
||||
// Action 1: Mark as read
|
||||
val markReadIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
|
||||
action = ACTION_MARK_READ
|
||||
data = Uri.parse("bulwark-action-read://$notificationId")
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(EXTRA_ACTION, "markRead")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
val markReadPending = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
(notificationId + ":read").hashCode(),
|
||||
markReadIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, markReadLabel, markReadPending).build()
|
||||
)
|
||||
|
||||
// Action 2: Delete
|
||||
val deleteIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
|
||||
action = ACTION_DELETE
|
||||
data = Uri.parse("bulwark-action-delete://$notificationId")
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(EXTRA_ACTION, "delete")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
val deletePending = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
(notificationId + ":delete").hashCode(),
|
||||
deleteIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, deleteLabel, deletePending)
|
||||
// Android 12+: unlock first, so a locked phone can't delete mail.
|
||||
.setAuthenticationRequired(true)
|
||||
.build()
|
||||
)
|
||||
|
||||
// Action 3: Reply
|
||||
val replyIntent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-reply://$notificationId")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
putExtra(EXTRA_ACTION, "reply")
|
||||
}
|
||||
val replyPending = PendingIntent.getActivity(
|
||||
ctx,
|
||||
(notificationId + ":reply").hashCode(),
|
||||
replyIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, replyLabel, replyPending).build()
|
||||
)
|
||||
}
|
||||
|
||||
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(notificationId, notificationId.hashCode(), builder.build())
|
||||
}
|
||||
@@ -157,7 +251,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
it.groupKey?.endsWith(groupKey) == true &&
|
||||
(it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0
|
||||
}
|
||||
val count = maxOf(children.size, 1)
|
||||
if (children.size < 2) {
|
||||
manager.cancel(groupKey, groupKey.hashCode())
|
||||
return
|
||||
}
|
||||
val count = children.size
|
||||
val inbox = NotificationCompat.InboxStyle()
|
||||
children.sortedByDescending { it.postTime }.take(5).forEach { sbn ->
|
||||
val extras = sbn.notification.extras
|
||||
@@ -172,6 +270,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-group-summary://$groupKey")
|
||||
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
}
|
||||
val pending = PendingIntent.getActivity(
|
||||
@@ -273,6 +372,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
|
||||
@Volatile private var currentInstance: BulwarkFcmModule? = null
|
||||
|
||||
const val EXTRA_ACTION = "bulwark.notification.action"
|
||||
const val EXTRA_NOTIFICATION_ID = "bulwark.notification.notificationId"
|
||||
const val ACTION_MARK_READ = "com.anonymous.bulwarkmobile.ACTION_MARK_READ"
|
||||
const val ACTION_DELETE = "com.anonymous.bulwarkmobile.ACTION_DELETE"
|
||||
|
||||
fun emit(eventName: String, params: WritableMap?) {
|
||||
val module = currentInstance ?: return
|
||||
val ctx = module.reactApplicationContext
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
|
||||
class BulwarkNotificationActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val notificationId = intent.getStringExtra(BulwarkFcmModule.EXTRA_NOTIFICATION_ID)
|
||||
val action = intent.getStringExtra(BulwarkFcmModule.EXTRA_ACTION)
|
||||
val emailId = intent.getStringExtra(NotificationTapStore.EXTRA_EMAIL_ID)
|
||||
val accountId = intent.getStringExtra(NotificationTapStore.EXTRA_ACCOUNT_ID)
|
||||
val jmapAccountId = intent.getStringExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID)
|
||||
|
||||
// Dismiss the notification immediately from the tray
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (notificationId != null) {
|
||||
manager.cancel(notificationId, notificationId.hashCode())
|
||||
}
|
||||
|
||||
if (emailId.isNullOrBlank() || action.isNullOrBlank() || accountId.isNullOrBlank()) {
|
||||
Log.w(TAG, "Missing parameters for notification action: action=$action, emailId=$emailId, accountId=$accountId")
|
||||
return
|
||||
}
|
||||
|
||||
// On Android 8+, a broadcast receiver in background cannot start a Service
|
||||
// (BackgroundServiceStartNotAllowedException). Instead, we use goAsync() and
|
||||
// HeadlessJs.withReadyReactContext to execute the headless JS task directly.
|
||||
val pendingResult = goAsync()
|
||||
val app = context.applicationContext as Application
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// Safety timeout to ensure pendingResult.finish() is always called
|
||||
val timeoutRunnable = Runnable {
|
||||
Log.w(TAG, "Notification action task timed out or failed to initialize ($action, $emailId)")
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
mainHandler.postDelayed(timeoutRunnable, 20_000L)
|
||||
|
||||
try {
|
||||
HeadlessJs.withReadyReactContext(app) { reactContext ->
|
||||
if (!reactContext.hasActiveReactInstance()) {
|
||||
Log.w(TAG, "React context does not have active instance")
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
return@withReadyReactContext
|
||||
}
|
||||
|
||||
val tasks = HeadlessJsTaskContext.getInstance(reactContext)
|
||||
val taskBundle = Bundle().apply {
|
||||
putString("action", action)
|
||||
putString("emailId", emailId)
|
||||
putString("accountId", accountId)
|
||||
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
||||
// Keep raw extras as well
|
||||
putString(BulwarkFcmModule.EXTRA_ACTION, action)
|
||||
putString(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putString(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putString(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
|
||||
val config = HeadlessJsTaskConfig(
|
||||
"BulwarkNotificationAction",
|
||||
Arguments.fromBundle(taskBundle),
|
||||
15_000L,
|
||||
true // allowExecutionInForeground
|
||||
)
|
||||
|
||||
val listener = object : HeadlessJsTaskEventListener {
|
||||
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
|
||||
override fun onHeadlessJsTaskFinish(taskId: Int) {
|
||||
Log.i(TAG, "Notification action task $taskId finished for action $action")
|
||||
tasks.removeTaskEventListener(this)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
tasks.addTaskEventListener(listener)
|
||||
|
||||
HeadlessJs.startTask(
|
||||
tasks,
|
||||
config,
|
||||
onStarted = { taskId ->
|
||||
Log.i(TAG, "Started notification action task $taskId for action $action ($emailId)")
|
||||
},
|
||||
onRefused = { e ->
|
||||
Log.w(TAG, "Notification action task refused: ${e.message}")
|
||||
tasks.removeTaskEventListener(listener)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to dispatch headless task from action receiver", e)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BulwarkNotifAction"
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,8 @@ object NotificationTapStore {
|
||||
// The JMAP account a group/shared mailbox's message lives in - a JMAP
|
||||
// id like the email id. Optional: older notifications don't carry it.
|
||||
val jmapAccountId = extras.getString(EXTRA_JMAP_ACCOUNT_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId)
|
||||
val action = extras.getString(EXTRA_ACTION)?.takeIf { it == "reply" }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId, action)
|
||||
pending = payload
|
||||
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
||||
extras.remove(EXTRA_EMAIL_ID)
|
||||
@@ -51,6 +52,7 @@ object NotificationTapStore {
|
||||
extras.remove(EXTRA_SUBJECT)
|
||||
extras.remove(EXTRA_ACCOUNT_ID)
|
||||
extras.remove(EXTRA_JMAP_ACCOUNT_ID)
|
||||
extras.remove(EXTRA_ACTION)
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -60,6 +62,7 @@ object NotificationTapStore {
|
||||
val subject: String?,
|
||||
val accountId: String?,
|
||||
val jmapAccountId: String?,
|
||||
val action: String? = null,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
putString("emailId", emailId)
|
||||
@@ -67,6 +70,7 @@ object NotificationTapStore {
|
||||
if (subject != null) putString("subject", subject)
|
||||
if (accountId != null) putString("accountId", accountId)
|
||||
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
||||
if (action != null) putString("action", action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,4 +79,5 @@ object NotificationTapStore {
|
||||
const val EXTRA_SUBJECT = "bulwark.notification.subject"
|
||||
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
|
||||
const val EXTRA_JMAP_ACCOUNT_ID = "bulwark.notification.jmapAccountId"
|
||||
const val EXTRA_ACTION = "bulwark.notification.action"
|
||||
}
|
||||
|
||||
+4
-1
@@ -31,7 +31,7 @@ module.exports = {
|
||||
// Android's manifest is maintained by hand and lists the same schemes.
|
||||
scheme: ['bulwarkmobile', 'bulwarkmail', 'mailto'],
|
||||
version: VERSION,
|
||||
orientation: 'portrait',
|
||||
orientation: 'default',
|
||||
icon: './assets/icon.png',
|
||||
userInterfaceStyle: 'automatic',
|
||||
newArchEnabled: true,
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,6 +10,5 @@ module.exports = function babelConfig(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [presetExpo],
|
||||
plugins: [require.resolve('./scripts/babel-plugin-lucide-imports')],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -142,6 +142,20 @@ Internal testers (up to 100, must be on your App Store Connect team) get it
|
||||
immediately. External testers (up to 10,000) need a Beta App Review, which takes
|
||||
about a day the first time and is usually waved through afterwards.
|
||||
|
||||
The workflow's `public-testflight` job does the external part on its own. It
|
||||
waits for processing, adds the build to the `external` group (the one behind
|
||||
the public link in the README), sets "What to Test" to the GitHub release and
|
||||
submits it for Beta App Review (`scripts/testflight-public.mjs`). To do this by
|
||||
hand for a build, for example after a failed run:
|
||||
|
||||
```bash
|
||||
ASC_KEY_ID=… ASC_ISSUER_ID=… ASC_KEY_PATH=path/to/AuthKey_….p8 node scripts/testflight-public.mjs 0.1.72 18 # marketing version, build number
|
||||
```
|
||||
|
||||
If App Store Connect answers `REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED` (altool
|
||||
reports it as "Cannot determine the Apple ID from Bundle ID"), the Account
|
||||
Holder has to accept an updated agreement under Business in App Store Connect.
|
||||
|
||||
## What does not work on iOS yet
|
||||
|
||||
- **Push notifications.** The push path is a hand-written Android module talking
|
||||
|
||||
@@ -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.
|
||||
@@ -16,6 +16,12 @@ AppRegistry.registerHeadlessTask('BulwarkPushTask', () => async (data: Parameter
|
||||
await Promise.allSettled([pushBackgroundTask(data), refreshWidgetsInBackground()]);
|
||||
});
|
||||
|
||||
// Notification quick actions (Mark as read, Delete): run without launching the app UI.
|
||||
AppRegistry.registerHeadlessTask('BulwarkNotificationAction', () => async (data: unknown) => {
|
||||
const { handleNotificationAction } = require('./src/lib/push-background-task') as typeof import('./src/lib/push-background-task');
|
||||
await Promise.allSettled([handleNotificationAction(data), refreshWidgetsInBackground()]);
|
||||
});
|
||||
|
||||
// Device sync (Android, #34): the contacts and calendar sync adapters run the
|
||||
// sync engine through this task, headless or next to the UI. The engine is
|
||||
// required on first use so an app start does not load it.
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "رابط",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "لا شيء مخطط. انقر للإنشاء.",
|
||||
"tap_to_create": "اضغط + لإنشاء حدث"
|
||||
},
|
||||
"fab": {
|
||||
"event": "حدث",
|
||||
"task": "مهمة"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "ملاحظات، جدول أعمال…",
|
||||
"location_placeholder": "المكان أو العنوان",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "لم تعد هذه المهمة متاحة.",
|
||||
"save_error": "فشل حفظ المهمة",
|
||||
"update_error": "فشل تحديث المهمة"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "الجدول",
|
||||
"three_days": "3 أيام"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "فتح في تطبيق",
|
||||
"pdf_external": "تُفتح ملفات PDF في عارض PDF على هذا الجهاز.",
|
||||
"unsupported": "لا توجد معاينة لهذا النوع من الملفات."
|
||||
"unsupported": "لا توجد معاينة لهذا النوع من الملفات.",
|
||||
"media_unsupported": "لا يمكن تشغيل هذا الملف هنا."
|
||||
},
|
||||
"received_via": "إلى {name}",
|
||||
"remove_color": "إزالة الوسم",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "تابع تقدّم البناء أو نزّل ملف APK يدويًا.",
|
||||
"severity_deprecated": "إلزامي",
|
||||
"severity_security": "أمني",
|
||||
"title": "تحديثات التطبيق"
|
||||
"title": "تحديثات التطبيق",
|
||||
"verifying": "جارٍ التحقق…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Enllaç",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Res planificat. Toca per crear.",
|
||||
"tap_to_create": "Toqueu + per crear-ne un"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Esdeveniment",
|
||||
"task": "Tasca"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notes, ordre del dia…",
|
||||
"location_placeholder": "Lloc o adreça",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Aquesta tasca ja no està disponible.",
|
||||
"save_error": "No s'ha pogut desar la tasca",
|
||||
"update_error": "No s'ha pogut actualitzar la tasca"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Agenda",
|
||||
"three_days": "3 dies"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Obre en una aplicació",
|
||||
"pdf_external": "Els PDF s'obren amb el visualitzador de PDF d'aquest dispositiu.",
|
||||
"unsupported": "No hi ha previsualització per a aquest tipus de fitxer."
|
||||
"unsupported": "No hi ha previsualització per a aquest tipus de fitxer.",
|
||||
"media_unsupported": "Aquest fitxer no es pot reproduir aquí."
|
||||
},
|
||||
"received_via": "per a {name}",
|
||||
"remove_color": "Elimina l'etiqueta",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Seguiu el progrés de la compilació o baixeu l'APK manualment.",
|
||||
"severity_deprecated": "Obligatòria",
|
||||
"severity_security": "Seguretat",
|
||||
"title": "Actualitzacions de l'aplicació"
|
||||
"title": "Actualitzacions de l'aplicació",
|
||||
"verifying": "S'està verificant…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Odkaz",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nic naplánováno. Klepnutím vytvoříte.",
|
||||
"tap_to_create": "Událost vytvoříte klepnutím na +"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Událost",
|
||||
"task": "Úkol"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Poznámky, program…",
|
||||
"location_placeholder": "Místo nebo adresa",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Tento úkol už není k dispozici.",
|
||||
"save_error": "Úkol se nepodařilo uložit",
|
||||
"update_error": "Úkol se nepodařilo aktualizovat"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Program",
|
||||
"three_days": "3 dny"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Otevřít v aplikaci",
|
||||
"pdf_external": "Soubory PDF se otevírají v prohlížeči PDF na tomto zařízení.",
|
||||
"unsupported": "Pro tento typ souboru není náhled k dispozici."
|
||||
"unsupported": "Pro tento typ souboru není náhled k dispozici.",
|
||||
"media_unsupported": "Tento soubor zde nelze přehrát."
|
||||
},
|
||||
"received_via": "pro {name}",
|
||||
"remove_color": "Odebrat štítek",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Sledujte průběh sestavení nebo si stáhněte APK ručně.",
|
||||
"severity_deprecated": "Povinná",
|
||||
"severity_security": "Zabezpečení",
|
||||
"title": "Aktualizace aplikace"
|
||||
"title": "Aktualizace aplikace",
|
||||
"verifying": "Ověřování…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Intet planlagt. Tryk for at oprette.",
|
||||
"tap_to_create": "Tryk på + for at oprette en"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Begivenhed",
|
||||
"task": "Opgave"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Noter, dagsorden…",
|
||||
"location_placeholder": "Sted eller adresse",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Denne opgave er ikke længere tilgængelig.",
|
||||
"save_error": "Kunne ikke gemme opgaven",
|
||||
"update_error": "Kunne ikke opdatere opgaven"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Tidsplan",
|
||||
"three_days": "3 dage"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Åbn i app",
|
||||
"pdf_external": "PDF'er åbnes i din PDF-fremviser på denne enhed.",
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtype."
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtype.",
|
||||
"media_unsupported": "Denne fil kan ikke afspilles her."
|
||||
},
|
||||
"received_via": "til {name}",
|
||||
"remove_color": "Fjern tag",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Følg buildets fremskridt, eller download APK'en manuelt.",
|
||||
"severity_deprecated": "Påkrævet",
|
||||
"severity_security": "Sikkerhed",
|
||||
"title": "Appopdateringer"
|
||||
"title": "Appopdateringer",
|
||||
"verifying": "Verificerer…"
|
||||
}
|
||||
}
|
||||
|
||||
+123
-6
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nichts geplant. Zum Erstellen tippen.",
|
||||
"tap_to_create": "Tippen Sie auf +, um einen zu erstellen"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Termin",
|
||||
"task": "Aufgabe"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notizen, Tagesordnung…",
|
||||
"location_placeholder": "Ort oder Adresse",
|
||||
@@ -123,10 +128,16 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -301,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...",
|
||||
@@ -398,7 +413,8 @@
|
||||
"preview": {
|
||||
"open_external": "In App öffnen",
|
||||
"pdf_external": "PDFs werden in Ihrem PDF-Viewer auf diesem Gerät geöffnet.",
|
||||
"unsupported": "Keine Vorschau für diesen Dateityp."
|
||||
"unsupported": "Keine Vorschau für diesen Dateityp.",
|
||||
"media_unsupported": "Diese Datei kann hier nicht abgespielt werden."
|
||||
},
|
||||
"received_via": "an {name}",
|
||||
"remove_color": "Label entfernen",
|
||||
@@ -438,6 +454,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",
|
||||
@@ -632,12 +649,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}}",
|
||||
@@ -857,6 +876,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",
|
||||
@@ -979,7 +999,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.",
|
||||
@@ -1087,7 +1115,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?",
|
||||
@@ -1111,6 +1142,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": {
|
||||
@@ -1119,6 +1230,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": {
|
||||
@@ -1168,7 +1284,8 @@
|
||||
"release_page_pending_desc": "Den Build-Fortschritt verfolgen oder die APK manuell herunterladen.",
|
||||
"severity_deprecated": "Erforderlich",
|
||||
"severity_security": "Sicherheit",
|
||||
"title": "App-Updates"
|
||||
"title": "App-Updates",
|
||||
"verifying": "Wird überprüft…"
|
||||
},
|
||||
"widgets": {
|
||||
"attachments": {
|
||||
|
||||
+122
-4
@@ -13,6 +13,9 @@
|
||||
"indigo": "Indigo",
|
||||
"teal": "Teal"
|
||||
},
|
||||
"detail": {
|
||||
"location_hint": "Opens the location; long-press to copy it"
|
||||
},
|
||||
"drawer": {
|
||||
"my_calendars": "My calendars",
|
||||
"no_calendars": "No calendars yet.",
|
||||
@@ -25,8 +28,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nothing planned. Tap to create.",
|
||||
"tap_to_create": "Tap + to create one"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Event",
|
||||
"task": "Task"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notes, agenda…",
|
||||
"location_placeholder": "Place or address",
|
||||
@@ -123,10 +131,16 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -309,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",
|
||||
@@ -408,7 +424,8 @@
|
||||
"preview": {
|
||||
"open_external": "Open in app",
|
||||
"pdf_external": "PDFs open in your PDF viewer on this device.",
|
||||
"unsupported": "No preview for this file type."
|
||||
"unsupported": "No preview for this file type.",
|
||||
"media_unsupported": "This file can't be played here."
|
||||
},
|
||||
"received_via": "to {name}",
|
||||
"remove_color": "Remove tag",
|
||||
@@ -448,6 +465,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",
|
||||
@@ -610,6 +628,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.",
|
||||
@@ -636,6 +672,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"
|
||||
@@ -644,9 +681,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": {
|
||||
@@ -873,6 +919,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",
|
||||
@@ -910,6 +957,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",
|
||||
@@ -975,23 +1028,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",
|
||||
@@ -1001,6 +1062,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}",
|
||||
@@ -1100,10 +1177,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}\"?",
|
||||
@@ -1123,6 +1229,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\""
|
||||
@@ -1135,6 +1247,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": {
|
||||
@@ -1184,7 +1301,8 @@
|
||||
"release_page_pending_desc": "Watch the build progress or download the APK manually.",
|
||||
"severity_deprecated": "Required",
|
||||
"severity_security": "Security",
|
||||
"title": "App updates"
|
||||
"title": "App updates",
|
||||
"verifying": "Verifying…"
|
||||
},
|
||||
"widgets": {
|
||||
"attachments": {
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Enlace",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nada planificado. Toca para crear.",
|
||||
"tap_to_create": "Toca + para crear uno"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Evento",
|
||||
"task": "Tarea"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notas, orden del día…",
|
||||
"location_placeholder": "Lugar o dirección",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Esta tarea ya no está disponible.",
|
||||
"save_error": "No se pudo guardar la tarea",
|
||||
"update_error": "No se pudo actualizar la tarea"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Agenda",
|
||||
"three_days": "3 días"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Abrir en una app",
|
||||
"pdf_external": "Los PDF se abren en el visor de PDF de este dispositivo.",
|
||||
"unsupported": "No hay vista previa para este tipo de archivo."
|
||||
"unsupported": "No hay vista previa para este tipo de archivo.",
|
||||
"media_unsupported": "Este archivo no se puede reproducir aquí."
|
||||
},
|
||||
"received_via": "para {name}",
|
||||
"remove_color": "Eliminar etiqueta",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Sigue el progreso de la compilación o descarga el APK manualmente.",
|
||||
"severity_deprecated": "Obligatoria",
|
||||
"severity_security": "Seguridad",
|
||||
"title": "Actualizaciones de la app"
|
||||
"title": "Actualizaciones de la app",
|
||||
"verifying": "Verificando…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "لینک",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "برنامهای نیست. برای ایجاد ضربه بزنید.",
|
||||
"tap_to_create": "برای ایجاد، روی + ضربه بزنید"
|
||||
},
|
||||
"fab": {
|
||||
"event": "رویداد",
|
||||
"task": "کار"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "یادداشتها، دستور جلسه…",
|
||||
"location_placeholder": "مکان یا نشانی",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "این وظیفه دیگر در دسترس نیست.",
|
||||
"save_error": "ذخیره وظیفه ناموفق بود",
|
||||
"update_error": "بهروزرسانی وظیفه ناموفق بود"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "برنامه",
|
||||
"three_days": "۳ روز"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "باز کردن در برنامه",
|
||||
"pdf_external": "فایلهای PDF در نمایشگر PDF این دستگاه باز میشوند.",
|
||||
"unsupported": "برای این نوع فایل پیشنمایشی وجود ندارد."
|
||||
"unsupported": "برای این نوع فایل پیشنمایشی وجود ندارد.",
|
||||
"media_unsupported": "این فایل اینجا پخش نمیشود."
|
||||
},
|
||||
"received_via": "به {name}",
|
||||
"remove_color": "حذف برچسب",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "روند ساخت را دنبال کنید یا APK را دستی دانلود کنید.",
|
||||
"severity_deprecated": "الزامی",
|
||||
"severity_security": "امنیتی",
|
||||
"title": "بهروزرسانیهای برنامه"
|
||||
"title": "بهروزرسانیهای برنامه",
|
||||
"verifying": "در حال بررسی…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Lien",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Rien de prévu. Touchez pour créer.",
|
||||
"tap_to_create": "Appuyez sur + pour en créer un"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Événement",
|
||||
"task": "Tâche"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notes, ordre du jour…",
|
||||
"location_placeholder": "Lieu ou adresse",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Cette tâche n'est plus disponible.",
|
||||
"save_error": "Échec de l'enregistrement de la tâche",
|
||||
"update_error": "Échec de la mise à jour de la tâche"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Planning",
|
||||
"three_days": "3 jours"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Ouvrir dans une app",
|
||||
"pdf_external": "Les PDF s'ouvrent dans votre lecteur PDF sur cet appareil.",
|
||||
"unsupported": "Aucun aperçu pour ce type de fichier."
|
||||
"unsupported": "Aucun aperçu pour ce type de fichier.",
|
||||
"media_unsupported": "Ce fichier ne peut pas être lu ici."
|
||||
},
|
||||
"received_via": "à {name}",
|
||||
"remove_color": "Retirer l'étiquette",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Suivez l'avancement de la compilation ou téléchargez l'APK manuellement.",
|
||||
"severity_deprecated": "Requise",
|
||||
"severity_security": "Sécurité",
|
||||
"title": "Mises à jour de l'application"
|
||||
"title": "Mises à jour de l'application",
|
||||
"verifying": "Vérification…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "קישור",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "אין תוכניות. הקש כדי ליצור.",
|
||||
"tap_to_create": "הקש על + כדי ליצור"
|
||||
},
|
||||
"fab": {
|
||||
"event": "אירוע",
|
||||
"task": "משימה"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "הערות, סדר יום…",
|
||||
"location_placeholder": "מקום או כתובת",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "המשימה הזו אינה זמינה עוד.",
|
||||
"save_error": "שמירת המשימה נכשלה",
|
||||
"update_error": "עדכון המשימה נכשל"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "לוח זמנים",
|
||||
"three_days": "3 ימים"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "פתח באפליקציה",
|
||||
"pdf_external": "קובצי PDF נפתחים במציג ה-PDF במכשיר זה.",
|
||||
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה."
|
||||
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה.",
|
||||
"media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן."
|
||||
},
|
||||
"received_via": "אל {name}",
|
||||
"remove_color": "הסר תג",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "עקוב אחר התקדמות הבנייה או הורד את ה-APK ידנית.",
|
||||
"severity_deprecated": "חובה",
|
||||
"severity_security": "אבטחה",
|
||||
"title": "עדכוני אפליקציה"
|
||||
"title": "עדכוני אפליקציה",
|
||||
"verifying": "מאמת…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Hivatkozás",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nincs terv. Koppintson a létrehozáshoz.",
|
||||
"tap_to_create": "Esemény létrehozásához koppints a + gombra"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Esemény",
|
||||
"task": "Feladat"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Jegyzetek, napirend…",
|
||||
"location_placeholder": "Hely vagy cím",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Ez a feladat már nem érhető el.",
|
||||
"save_error": "Nem sikerült menteni a feladatot",
|
||||
"update_error": "Nem sikerült frissíteni a feladatot"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Ütemezés",
|
||||
"three_days": "3 nap"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Megnyitás alkalmazásban",
|
||||
"pdf_external": "A PDF-fájlok az eszköz PDF-megjelenítőjében nyílnak meg.",
|
||||
"unsupported": "Ehhez a fájltípushoz nincs előnézet."
|
||||
"unsupported": "Ehhez a fájltípushoz nincs előnézet.",
|
||||
"media_unsupported": "Ez a fájl itt nem játszható le."
|
||||
},
|
||||
"received_via": "címzett: {name}",
|
||||
"remove_color": "Címke eltávolítása",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Kövesd a build folyamatát, vagy töltsd le kézzel az APK-t.",
|
||||
"severity_deprecated": "Kötelező",
|
||||
"severity_security": "Biztonsági",
|
||||
"title": "Alkalmazásfrissítések"
|
||||
"title": "Alkalmazásfrissítések",
|
||||
"verifying": "Ellenőrzés…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Niente in programma. Tocca per creare.",
|
||||
"tap_to_create": "Tocca + per crearne uno"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Evento",
|
||||
"task": "Attività"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Note, ordine del giorno…",
|
||||
"location_placeholder": "Luogo o indirizzo",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Questa attività non è più disponibile.",
|
||||
"save_error": "Impossibile salvare l'attività",
|
||||
"update_error": "Impossibile aggiornare l'attività"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Programma",
|
||||
"three_days": "3 giorni"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Apri in un'app",
|
||||
"pdf_external": "I PDF si aprono nel lettore PDF di questo dispositivo.",
|
||||
"unsupported": "Nessuna anteprima per questo tipo di file."
|
||||
"unsupported": "Nessuna anteprima per questo tipo di file.",
|
||||
"media_unsupported": "Questo file non può essere riprodotto qui."
|
||||
},
|
||||
"received_via": "a {name}",
|
||||
"remove_color": "Rimuovi etichetta",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Segui l'avanzamento della build o scarica l'APK manualmente.",
|
||||
"severity_deprecated": "Obbligatorio",
|
||||
"severity_security": "Sicurezza",
|
||||
"title": "Aggiornamenti dell'app"
|
||||
"title": "Aggiornamenti dell'app",
|
||||
"verifying": "Verifica in corso…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "リンク",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "予定はありません。タップして作成",
|
||||
"tap_to_create": "+ をタップして作成"
|
||||
},
|
||||
"fab": {
|
||||
"event": "予定",
|
||||
"task": "タスク"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "メモ、議題…",
|
||||
"location_placeholder": "場所または住所",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "このタスクは利用できなくなりました。",
|
||||
"save_error": "タスクの保存に失敗しました",
|
||||
"update_error": "タスクの更新に失敗しました"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "スケジュール",
|
||||
"three_days": "3 日"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "アプリで開く",
|
||||
"pdf_external": "PDF はこのデバイスの PDF ビューアーで開きます。",
|
||||
"unsupported": "このファイル形式はプレビューできません。"
|
||||
"unsupported": "このファイル形式はプレビューできません。",
|
||||
"media_unsupported": "このファイルはここでは再生できません。"
|
||||
},
|
||||
"received_via": "{name}宛",
|
||||
"remove_color": "ラベルを削除",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "ビルドの進行状況を確認するか、APK を手動でダウンロードします。",
|
||||
"severity_deprecated": "必須",
|
||||
"severity_security": "セキュリティ",
|
||||
"title": "アプリのアップデート"
|
||||
"title": "アプリのアップデート",
|
||||
"verifying": "検証中…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "링크",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "계획된 일정이 없습니다. 탭하여 만드세요.",
|
||||
"tap_to_create": "+를 눌러 만들어 보세요"
|
||||
},
|
||||
"fab": {
|
||||
"event": "일정",
|
||||
"task": "할 일"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "메모, 안건…",
|
||||
"location_placeholder": "장소 또는 주소",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "이 할 일을 더 이상 찾을 수 없어요.",
|
||||
"save_error": "할 일을 저장하지 못했어요",
|
||||
"update_error": "할 일을 업데이트하지 못했어요"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "일정",
|
||||
"three_days": "3일"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "앱에서 열기",
|
||||
"pdf_external": "PDF는 이 기기의 PDF 뷰어에서 열려요.",
|
||||
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요."
|
||||
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.",
|
||||
"media_unsupported": "이 파일은 여기서 재생할 수 없습니다."
|
||||
},
|
||||
"received_via": "받는 사람: {name}",
|
||||
"remove_color": "태그 제거",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "빌드 진행 상황을 확인하거나 APK를 직접 다운로드해요.",
|
||||
"severity_deprecated": "필수",
|
||||
"severity_security": "보안",
|
||||
"title": "앱 업데이트"
|
||||
"title": "앱 업데이트",
|
||||
"verifying": "확인 중…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Saite",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nekas nav ieplānots. Pieskarieties, lai izveidotu.",
|
||||
"tap_to_create": "Pieskarieties +, lai izveidotu notikumu"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Notikums",
|
||||
"task": "Uzdevums"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Piezīmes, darba kārtība…",
|
||||
"location_placeholder": "Vieta vai adrese",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Šis uzdevums vairs nav pieejams.",
|
||||
"save_error": "Neizdevās saglabāt uzdevumu",
|
||||
"update_error": "Neizdevās atjaunināt uzdevumu"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Grafiks",
|
||||
"three_days": "3 dienas"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Atvērt lietotnē",
|
||||
"pdf_external": "PDF faili tiek atvērti šīs ierīces PDF skatītājā.",
|
||||
"unsupported": "Šim faila tipam nav priekšskatījuma."
|
||||
"unsupported": "Šim faila tipam nav priekšskatījuma.",
|
||||
"media_unsupported": "Šo failu šeit nevar atskaņot."
|
||||
},
|
||||
"received_via": "kam: {name}",
|
||||
"remove_color": "Noņemt tagu",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Sekojiet būvēšanas gaitai vai lejupielādējiet APK manuāli.",
|
||||
"severity_deprecated": "Obligāts",
|
||||
"severity_security": "Drošība",
|
||||
"title": "Lietotnes atjauninājumi"
|
||||
"title": "Lietotnes atjauninājumi",
|
||||
"verifying": "Pārbauda…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Холбоос",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Төлөвлөсөн зүйл алга. Үүсгэхийн тулд товшино уу.",
|
||||
"tap_to_create": "Үүсгэхийн тулд + товшино уу"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Үйл явдал",
|
||||
"task": "Даалгавар"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Тэмдэглэл, хэлэлцэх асуудал…",
|
||||
"location_placeholder": "Газар эсвэл хаяг",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Энэ даалгавар байхгүй болсон.",
|
||||
"save_error": "Даалгаврыг хадгалж чадсангүй",
|
||||
"update_error": "Даалгаврыг шинэчилж чадсангүй"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Хуваарь",
|
||||
"three_days": "3 өдөр"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Апп-д нээх",
|
||||
"pdf_external": "PDF файлууд энэ төхөөрөмжийн PDF үзэгчид нээгдэнэ.",
|
||||
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй."
|
||||
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.",
|
||||
"media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй."
|
||||
},
|
||||
"received_via": "Хэнд: {name}",
|
||||
"remove_color": "Тагийг хасах",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Бүтээлтийн явцыг хянах эсвэл APK-г гараар татаж авах.",
|
||||
"severity_deprecated": "Заавал",
|
||||
"severity_security": "Аюулгүй байдал",
|
||||
"title": "Аппын шинэчлэл"
|
||||
"title": "Аппын шинэчлэл",
|
||||
"verifying": "Шалгаж байна…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Lenke",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Ingenting planlagt. Trykk for å opprette.",
|
||||
"tap_to_create": "Trykk på + for å opprette en"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Hendelse",
|
||||
"task": "Oppgave"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notater, agenda…",
|
||||
"location_placeholder": "Sted eller adresse",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Denne oppgaven er ikke lenger tilgjengelig.",
|
||||
"save_error": "Kunne ikke lagre oppgaven",
|
||||
"update_error": "Kunne ikke oppdatere oppgaven"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Timeplan",
|
||||
"three_days": "3 dager"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Åpne i app",
|
||||
"pdf_external": "PDF-filer åpnes i PDF-leseren på denne enheten.",
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtypen."
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtypen.",
|
||||
"media_unsupported": "Denne filen kan ikke spilles av her."
|
||||
},
|
||||
"received_via": "til {name}",
|
||||
"remove_color": "Fjern etiketten",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Følg fremdriften for byggingen, eller last ned APK-filen manuelt.",
|
||||
"severity_deprecated": "Påkrevd",
|
||||
"severity_security": "Sikkerhet",
|
||||
"title": "Appoppdateringer"
|
||||
"title": "Appoppdateringer",
|
||||
"verifying": "Verifiserer…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Niets gepland. Tik om aan te maken.",
|
||||
"tap_to_create": "Tik op + om er een te maken"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Afspraak",
|
||||
"task": "Taak"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notities, agendapunten…",
|
||||
"location_placeholder": "Plaats of adres",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Deze taak is niet meer beschikbaar.",
|
||||
"save_error": "Taak opslaan mislukt",
|
||||
"update_error": "Taak bijwerken mislukt"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Planning",
|
||||
"three_days": "3 dagen"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Openen in app",
|
||||
"pdf_external": "PDF's worden geopend in de PDF-viewer op dit apparaat.",
|
||||
"unsupported": "Geen voorbeeld voor dit bestandstype."
|
||||
"unsupported": "Geen voorbeeld voor dit bestandstype.",
|
||||
"media_unsupported": "Dit bestand kan hier niet worden afgespeeld."
|
||||
},
|
||||
"received_via": "aan {name}",
|
||||
"remove_color": "Label verwijderen",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Volg de voortgang van de build of download de APK handmatig.",
|
||||
"severity_deprecated": "Vereist",
|
||||
"severity_security": "Beveiliging",
|
||||
"title": "App-updates"
|
||||
"title": "App-updates",
|
||||
"verifying": "Controleren…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nic nie zaplanowano. Dotknij, aby utworzyć.",
|
||||
"tap_to_create": "Stuknij +, aby utworzyć wydarzenie"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Wydarzenie",
|
||||
"task": "Zadanie"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notatki, agenda…",
|
||||
"location_placeholder": "Miejsce lub adres",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "To zadanie nie jest już dostępne.",
|
||||
"save_error": "Nie udało się zapisać zadania",
|
||||
"update_error": "Nie udało się zaktualizować zadania"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Harmonogram",
|
||||
"three_days": "3 dni"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Otwórz w aplikacji",
|
||||
"pdf_external": "Pliki PDF otwierają się w przeglądarce PDF na tym urządzeniu.",
|
||||
"unsupported": "Brak podglądu dla tego typu pliku."
|
||||
"unsupported": "Brak podglądu dla tego typu pliku.",
|
||||
"media_unsupported": "Tego pliku nie można tutaj odtworzyć."
|
||||
},
|
||||
"received_via": "do {name}",
|
||||
"remove_color": "Usuń etykietę",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Śledź postęp budowania lub pobierz plik APK ręcznie.",
|
||||
"severity_deprecated": "Wymagana",
|
||||
"severity_security": "Bezpieczeństwo",
|
||||
"title": "Aktualizacje aplikacji"
|
||||
"title": "Aktualizacje aplikacji",
|
||||
"verifying": "Weryfikowanie…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nada planejado. Toque para criar.",
|
||||
"tap_to_create": "Toque em + para criar um"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Evento",
|
||||
"task": "Tarefa"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notas, pauta…",
|
||||
"location_placeholder": "Local ou endereço",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Esta tarefa não está mais disponível.",
|
||||
"save_error": "Falha ao salvar a tarefa",
|
||||
"update_error": "Falha ao atualizar a tarefa"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Programação",
|
||||
"three_days": "3 dias"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Abrir em outro app",
|
||||
"pdf_external": "PDFs são abertos no leitor de PDF deste dispositivo.",
|
||||
"unsupported": "Sem pré-visualização para este tipo de arquivo."
|
||||
"unsupported": "Sem pré-visualização para este tipo de arquivo.",
|
||||
"media_unsupported": "Este ficheiro não pode ser reproduzido aqui."
|
||||
},
|
||||
"received_via": "para {name}",
|
||||
"remove_color": "Remover etiqueta",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Acompanhe o progresso da compilação ou baixe o APK manualmente.",
|
||||
"severity_deprecated": "Obrigatória",
|
||||
"severity_security": "Segurança",
|
||||
"title": "Atualizações do app"
|
||||
"title": "Atualizações do app",
|
||||
"verifying": "Verificando…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Link",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nimic planificat. Atinge pentru a crea.",
|
||||
"tap_to_create": "Atingeți + pentru a crea unul"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Eveniment",
|
||||
"task": "Sarcină"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Note, agendă…",
|
||||
"location_placeholder": "Loc sau adresă",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Această sarcină nu mai este disponibilă.",
|
||||
"save_error": "Sarcina nu a putut fi salvată",
|
||||
"update_error": "Sarcina nu a putut fi actualizată"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Program",
|
||||
"three_days": "3 zile"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Deschideți într-o aplicație",
|
||||
"pdf_external": "Fișierele PDF se deschid în vizualizatorul PDF de pe acest dispozitiv.",
|
||||
"unsupported": "Nu există previzualizare pentru acest tip de fișier."
|
||||
"unsupported": "Nu există previzualizare pentru acest tip de fișier.",
|
||||
"media_unsupported": "Acest fișier nu poate fi redat aici."
|
||||
},
|
||||
"received_via": "către {name}",
|
||||
"remove_color": "Eliminați eticheta",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Urmăriți progresul compilării sau descărcați manual APK-ul.",
|
||||
"severity_deprecated": "Obligatorie",
|
||||
"severity_security": "Securitate",
|
||||
"title": "Actualizări aplicație"
|
||||
"title": "Actualizări aplicație",
|
||||
"verifying": "Se verifică…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Ссылка",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Ничего не запланировано. Нажмите, чтобы создать.",
|
||||
"tap_to_create": "Нажмите +, чтобы создать"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Мероприятие",
|
||||
"task": "Задача"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Заметки, повестка…",
|
||||
"location_placeholder": "Место или адрес",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Эта задача больше недоступна.",
|
||||
"save_error": "Не удалось сохранить задачу",
|
||||
"update_error": "Не удалось обновить задачу"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Расписание",
|
||||
"three_days": "3 дня"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Открыть в приложении",
|
||||
"pdf_external": "PDF-файлы открываются в программе просмотра PDF на этом устройстве.",
|
||||
"unsupported": "Для этого типа файлов нет предпросмотра."
|
||||
"unsupported": "Для этого типа файлов нет предпросмотра.",
|
||||
"media_unsupported": "Этот файл нельзя воспроизвести здесь."
|
||||
},
|
||||
"received_via": "на {name}",
|
||||
"remove_color": "Убрать тег",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Следите за ходом сборки или скачайте APK вручную.",
|
||||
"severity_deprecated": "Обязательное",
|
||||
"severity_security": "Безопасность",
|
||||
"title": "Обновления приложения"
|
||||
"title": "Обновления приложения",
|
||||
"verifying": "Проверка…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Odkaz",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Nič nie je naplánované. Ťuknutím vytvoríte.",
|
||||
"tap_to_create": "Udalosť vytvoríte klepnutím na +"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Udalosť",
|
||||
"task": "Úloha"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Poznámky, program…",
|
||||
"location_placeholder": "Miesto alebo adresa",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Táto úloha už nie je k dispozícii.",
|
||||
"save_error": "Úlohu sa nepodarilo uložiť",
|
||||
"update_error": "Úlohu sa nepodarilo aktualizovať"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Program",
|
||||
"three_days": "3 dni"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Otvoriť v aplikácii",
|
||||
"pdf_external": "Súbory PDF sa otvárajú v prehliadači PDF na tomto zariadení.",
|
||||
"unsupported": "Pre tento typ súboru nie je k dispozícii náhľad."
|
||||
"unsupported": "Pre tento typ súboru nie je k dispozícii náhľad.",
|
||||
"media_unsupported": "Tento súbor tu nie je možné prehrať."
|
||||
},
|
||||
"received_via": "pre {name}",
|
||||
"remove_color": "Odstrániť štítok",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Sledujte priebeh zostavenia alebo si stiahnite APK ručne.",
|
||||
"severity_deprecated": "Povinná",
|
||||
"severity_security": "Zabezpečenie",
|
||||
"title": "Aktualizácie aplikácie"
|
||||
"title": "Aktualizácie aplikácie",
|
||||
"verifying": "Overovanie…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Bağlantı",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Planlanmış bir şey yok. Oluşturmak için dokunun.",
|
||||
"tap_to_create": "Etkinlik oluşturmak için + simgesine dokunun"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Etkinlik",
|
||||
"task": "Görev"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Notlar, gündem…",
|
||||
"location_placeholder": "Yer veya adres",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Bu görev artık kullanılamıyor.",
|
||||
"save_error": "Görev kaydedilemedi",
|
||||
"update_error": "Görev güncellenemedi"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Program",
|
||||
"three_days": "3 gün"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Uygulamada aç",
|
||||
"pdf_external": "PDF dosyaları bu cihazdaki PDF görüntüleyicide açılır.",
|
||||
"unsupported": "Bu dosya türü için önizleme yok."
|
||||
"unsupported": "Bu dosya türü için önizleme yok.",
|
||||
"media_unsupported": "Bu dosya burada oynatılamıyor."
|
||||
},
|
||||
"received_via": "alıcı: {name}",
|
||||
"remove_color": "Etiketi kaldır",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Derleme ilerlemesini izleyin veya APK'yı elle indirin.",
|
||||
"severity_deprecated": "Zorunlu",
|
||||
"severity_security": "Güvenlik",
|
||||
"title": "Uygulama güncellemeleri"
|
||||
"title": "Uygulama güncellemeleri",
|
||||
"verifying": "Doğrulanıyor…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "Посилання",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "Нічого не заплановано. Торкніться, щоб створити.",
|
||||
"tap_to_create": "Натисніть +, щоб створити"
|
||||
},
|
||||
"fab": {
|
||||
"event": "Подія",
|
||||
"task": "Завдання"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "Нотатки, порядок денний…",
|
||||
"location_placeholder": "Місце або адреса",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "Це завдання більше недоступне.",
|
||||
"save_error": "Не вдалося зберегти завдання",
|
||||
"update_error": "Не вдалося оновити завдання"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "Розклад",
|
||||
"three_days": "3 дні"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Відкрити в застосунку",
|
||||
"pdf_external": "PDF-файли відкриваються в переглядачі PDF на цьому пристрої.",
|
||||
"unsupported": "Для цього типу файлів немає попереднього перегляду."
|
||||
"unsupported": "Для цього типу файлів немає попереднього перегляду.",
|
||||
"media_unsupported": "Цей файл не можна відтворити тут."
|
||||
},
|
||||
"received_via": "на {name}",
|
||||
"remove_color": "Прибрати тег",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "Стежте за перебігом збирання або завантажте APK вручну.",
|
||||
"severity_deprecated": "Обов'язкове",
|
||||
"severity_security": "Безпека",
|
||||
"title": "Оновлення застосунку"
|
||||
"title": "Оновлення застосунку",
|
||||
"verifying": "Перевірка…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "連結",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "沒有安排。輕觸即可建立。",
|
||||
"tap_to_create": "點選 + 即可建立"
|
||||
},
|
||||
"fab": {
|
||||
"event": "活動",
|
||||
"task": "工作"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "備註、議程…",
|
||||
"location_placeholder": "地點或地址",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "此任務已不存在。",
|
||||
"save_error": "儲存任務失敗",
|
||||
"update_error": "更新任務失敗"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "行程",
|
||||
"three_days": "3 天"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "在應用程式中開啟",
|
||||
"pdf_external": "PDF 會在此裝置的 PDF 檢視器中開啟。",
|
||||
"unsupported": "無法預覽此檔案類型。"
|
||||
"unsupported": "無法預覽此檔案類型。",
|
||||
"media_unsupported": "無法在此播放這個檔案。"
|
||||
},
|
||||
"received_via": "寄給 {name}",
|
||||
"remove_color": "移除標籤",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "查看建置進度或手動下載 APK。",
|
||||
"severity_deprecated": "必要",
|
||||
"severity_security": "安全性",
|
||||
"title": "應用程式更新"
|
||||
"title": "應用程式更新",
|
||||
"verifying": "正在驗證…"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,8 +25,13 @@
|
||||
"events": {
|
||||
"copy_link_short": "链接",
|
||||
"export_ics_short": ".ics",
|
||||
"nothing_planned": "暂无安排。点按即可创建。",
|
||||
"tap_to_create": "点按 + 创建"
|
||||
},
|
||||
"fab": {
|
||||
"event": "活动",
|
||||
"task": "任务"
|
||||
},
|
||||
"form": {
|
||||
"description_placeholder": "备注、议程…",
|
||||
"location_placeholder": "地点或地址",
|
||||
@@ -127,6 +132,10 @@
|
||||
"not_found": "此任务已不存在。",
|
||||
"save_error": "保存任务失败",
|
||||
"update_error": "更新任务失败"
|
||||
},
|
||||
"views": {
|
||||
"schedule": "日程",
|
||||
"three_days": "3 天"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -398,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "在应用中打开",
|
||||
"pdf_external": "PDF 将在此设备的 PDF 查看器中打开。",
|
||||
"unsupported": "无法预览此类文件。"
|
||||
"unsupported": "无法预览此类文件。",
|
||||
"media_unsupported": "无法在此播放该文件。"
|
||||
},
|
||||
"received_via": "发给 {name}",
|
||||
"remove_color": "删除标签",
|
||||
@@ -1168,6 +1178,7 @@
|
||||
"release_page_pending_desc": "查看构建进度或手动下载 APK。",
|
||||
"severity_deprecated": "必需",
|
||||
"severity_security": "安全",
|
||||
"title": "应用更新"
|
||||
"title": "应用更新",
|
||||
"verifying": "正在验证…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+3
-14
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.70",
|
||||
"version": "0.1.80",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.70",
|
||||
"version": "0.1.80",
|
||||
"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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.70",
|
||||
"version": "0.1.80",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
diff --git a/node_modules/postal-mime/src/base64-decoder.js b/node_modules/postal-mime/src/base64-decoder.js
|
||||
index 93c2aea..ba6e3f1 100644
|
||||
--- a/node_modules/postal-mime/src/base64-decoder.js
|
||||
+++ b/node_modules/postal-mime/src/base64-decoder.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { decodeBase64, blobToArrayBuffer } from './decode-strings.js';
|
||||
+import { decodeBase64, concatChunks } from './decode-strings.js';
|
||||
|
||||
export default class Base64Decoder {
|
||||
constructor(opts) {
|
||||
@@ -49,6 +49,6 @@ export default class Base64Decoder {
|
||||
finalize() {
|
||||
this.flushRemainder();
|
||||
|
||||
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
+ return concatChunks(this.chunks);
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/postal-mime/src/decode-strings.js b/node_modules/postal-mime/src/decode-strings.js
|
||||
index a5e058e..f255e8d 100644
|
||||
--- a/node_modules/postal-mime/src/decode-strings.js
|
||||
+++ b/node_modules/postal-mime/src/decode-strings.js
|
||||
@@ -145,6 +145,23 @@ export function getDecoder(charset) {
|
||||
* @param {Blob} blob Blob to convert
|
||||
* @returns {ArrayBuffer} Converted value
|
||||
*/
|
||||
+// React Native's Blob cannot be built from ArrayBuffers or typed arrays, so
|
||||
+// the decoders join their chunks here instead of through a Blob.
|
||||
+export function concatChunks(chunks) {
|
||||
+ const parts = chunks.map(chunk => {
|
||||
+ if (typeof chunk === 'string') return textEncoder.encode(chunk);
|
||||
+ if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk);
|
||||
+ return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
||||
+ });
|
||||
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.byteLength, 0));
|
||||
+ let pos = 0;
|
||||
+ for (const part of parts) {
|
||||
+ out.set(part, pos);
|
||||
+ pos += part.byteLength;
|
||||
+ }
|
||||
+ return out.buffer;
|
||||
+}
|
||||
+
|
||||
export async function blobToArrayBuffer(blob) {
|
||||
if ('arrayBuffer' in blob) {
|
||||
return await blob.arrayBuffer();
|
||||
diff --git a/node_modules/postal-mime/src/pass-through-decoder.js b/node_modules/postal-mime/src/pass-through-decoder.js
|
||||
index ad1c222..1e1d012 100644
|
||||
--- a/node_modules/postal-mime/src/pass-through-decoder.js
|
||||
+++ b/node_modules/postal-mime/src/pass-through-decoder.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { blobToArrayBuffer } from './decode-strings.js';
|
||||
+import { concatChunks } from './decode-strings.js';
|
||||
|
||||
export default class PassThroughDecoder {
|
||||
constructor() {
|
||||
@@ -12,6 +12,6 @@ export default class PassThroughDecoder {
|
||||
|
||||
finalize() {
|
||||
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
|
||||
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
+ return concatChunks(this.chunks);
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/postal-mime/src/qp-decoder.js b/node_modules/postal-mime/src/qp-decoder.js
|
||||
index adee72c..3973673 100644
|
||||
--- a/node_modules/postal-mime/src/qp-decoder.js
|
||||
+++ b/node_modules/postal-mime/src/qp-decoder.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { blobToArrayBuffer, hexNibble } from './decode-strings.js';
|
||||
+import { concatChunks, hexNibble } from './decode-strings.js';
|
||||
|
||||
const CHR_EQUALS = 0x3d;
|
||||
const CHR_LF = 0x0a;
|
||||
@@ -83,6 +83,6 @@ export default class QPDecoder {
|
||||
this.flushBuffer();
|
||||
|
||||
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
|
||||
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
+ return concatChunks(this.chunks);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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`);
|
||||
@@ -0,0 +1,171 @@
|
||||
// Put an uploaded build on the public TestFlight link: wait until App Store
|
||||
// Connect has processed it, add it to the external beta group, set its
|
||||
// "What to Test" text and submit it for beta review. Builds only reach
|
||||
// external testers (and so the public link) once Apple approves them.
|
||||
//
|
||||
// node scripts/testflight-public.mjs <marketing version> <build number>
|
||||
//
|
||||
// Environment:
|
||||
// ASC_KEY_ID, ASC_ISSUER_ID App Store Connect API key
|
||||
// ASC_KEY_PATH the .p8 file (default ~/private_keys/AuthKey_<id>.p8)
|
||||
// BUNDLE_ID default org.bulwarkmail.mobile
|
||||
// TESTFLIGHT_GROUP external group name (default "external")
|
||||
// WHATS_NEW "What to Test" text (default: link to the GitHub release)
|
||||
//
|
||||
// Every step can be repeated: a build already in the group, already described
|
||||
// or already submitted is left as it is, so a re-run picks up where the last
|
||||
// one stopped.
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const [version, buildNumber] = process.argv.slice(2);
|
||||
if (!version || !buildNumber) {
|
||||
console.error('usage: node scripts/testflight-public.mjs <marketing version> <build number>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const keyId = process.env.ASC_KEY_ID;
|
||||
const issuer = process.env.ASC_ISSUER_ID;
|
||||
if (!keyId || !issuer) {
|
||||
console.error('ASC_KEY_ID and ASC_ISSUER_ID are required.');
|
||||
process.exit(2);
|
||||
}
|
||||
const keyPath = process.env.ASC_KEY_PATH || path.join(os.homedir(), 'private_keys', `AuthKey_${keyId}.p8`);
|
||||
const privateKey = fs.readFileSync(keyPath, 'utf8');
|
||||
const bundleId = process.env.BUNDLE_ID || 'org.bulwarkmail.mobile';
|
||||
const groupName = process.env.TESTFLIGHT_GROUP || 'external';
|
||||
const repo = process.env.GITHUB_REPOSITORY || 'bulwarkmail/native';
|
||||
const whatsNew = process.env.WHATS_NEW || `Release notes: https://github.com/${repo}/releases/tag/${version}`;
|
||||
|
||||
// Processing usually takes 5-30 minutes; give up well inside the job timeout.
|
||||
const PROCESSING_TIMEOUT_MS = 60 * 60 * 1000;
|
||||
const POLL_MS = 30 * 1000;
|
||||
|
||||
function token() {
|
||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const head = b64({ alg: 'ES256', kid: keyId, typ: 'JWT' });
|
||||
const body = b64({ iss: issuer, iat: now, exp: now + 15 * 60, aud: 'appstoreconnect-v1' });
|
||||
const sig = crypto
|
||||
.sign('sha256', Buffer.from(`${head}.${body}`), { key: privateKey, dsaEncoding: 'ieee-p1363' })
|
||||
.toString('base64url');
|
||||
return `${head}.${body}.${sig}`;
|
||||
}
|
||||
|
||||
async function api(method, p, body) {
|
||||
const res = await fetch(`https://api.appstoreconnect.apple.com${p}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
const json = text ? JSON.parse(text) : null;
|
||||
return { status: res.status, ok: res.ok, json };
|
||||
}
|
||||
|
||||
function fail(step, r) {
|
||||
const e = r.json?.errors?.[0];
|
||||
console.error(`${step} failed: ${r.status} ${e?.code ?? ''} ${e?.detail ?? e?.title ?? ''}`);
|
||||
if (e?.code === 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED') {
|
||||
console.error('An Apple agreement needs accepting in App Store Connect > Business.');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ── The app and the group ──────────────────────────────────────
|
||||
const apps = await api('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&fields[apps]=name`);
|
||||
if (!apps.ok) fail('Looking up the app', apps);
|
||||
const app = apps.json.data[0];
|
||||
if (!app) {
|
||||
console.error(`No app with bundle ID ${bundleId} in App Store Connect.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const groups = await api(
|
||||
'GET',
|
||||
`/v1/betaGroups?filter[app]=${app.id}&filter[name]=${encodeURIComponent(groupName)}&fields[betaGroups]=name,isInternalGroup,publicLink,publicLinkEnabled`,
|
||||
);
|
||||
if (!groups.ok) fail('Looking up the beta group', groups);
|
||||
const group = groups.json.data.find((g) => !g.attributes.isInternalGroup && g.attributes.name === groupName);
|
||||
if (!group) {
|
||||
console.error(`No external beta group named "${groupName}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Wait for the build ─────────────────────────────────────────
|
||||
const query =
|
||||
`/v1/builds?filter[app]=${app.id}&filter[version]=${encodeURIComponent(buildNumber)}` +
|
||||
`&filter[preReleaseVersion.version]=${encodeURIComponent(version)}&fields[builds]=version,processingState`;
|
||||
const started = Date.now();
|
||||
let build;
|
||||
for (;;) {
|
||||
const r = await api('GET', query);
|
||||
if (!r.ok) fail('Looking up the build', r);
|
||||
build = r.json.data[0];
|
||||
const state = build?.attributes.processingState;
|
||||
if (state === 'VALID') break;
|
||||
if (state === 'FAILED' || state === 'INVALID') {
|
||||
console.error(`Build ${version} (${buildNumber}) is ${state} in App Store Connect.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (Date.now() - started > PROCESSING_TIMEOUT_MS) {
|
||||
console.error(`Build ${version} (${buildNumber}) was not processed within an hour (state: ${state ?? 'not found'}).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Build ${version} (${buildNumber}): ${state ?? 'not visible yet'}, waiting…`);
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
console.log(`Build ${version} (${buildNumber}) is processed.`);
|
||||
|
||||
// ── Add it to the group ────────────────────────────────────────
|
||||
const add = await api('POST', `/v1/betaGroups/${group.id}/relationships/builds`, {
|
||||
data: [{ type: 'builds', id: build.id }],
|
||||
});
|
||||
if (!add.ok) fail(`Adding the build to "${groupName}"`, add);
|
||||
console.log(`Added to the "${groupName}" group.`);
|
||||
|
||||
// ── "What to Test" ─────────────────────────────────────────────
|
||||
const locs = await api('GET', `/v1/builds/${build.id}/betaBuildLocalizations`);
|
||||
if (!locs.ok) fail('Reading the build notes', locs);
|
||||
const en = locs.json.data.find((l) => l.attributes.locale === 'en-US');
|
||||
const notes = en
|
||||
? await api('PATCH', `/v1/betaBuildLocalizations/${en.id}`, {
|
||||
data: { type: 'betaBuildLocalizations', id: en.id, attributes: { whatsNew } },
|
||||
})
|
||||
: await api('POST', '/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: 'en-US', whatsNew },
|
||||
relationships: { build: { data: { type: 'builds', id: build.id } } },
|
||||
},
|
||||
});
|
||||
if (!notes.ok) fail('Setting "What to Test"', notes);
|
||||
|
||||
// ── Beta review ────────────────────────────────────────────────
|
||||
const detail = await api('GET', `/v1/builds/${build.id}/buildBetaDetail`);
|
||||
if (!detail.ok) fail('Reading the build state', detail);
|
||||
const external = detail.json.data.attributes.externalBuildState;
|
||||
if (external === 'MISSING_EXPORT_COMPLIANCE') {
|
||||
console.error('The build has no export compliance answer; set usesNonExemptEncryption in app.config.js.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (external === 'READY_FOR_BETA_SUBMISSION') {
|
||||
const submit = await api('POST', '/v1/betaAppReviewSubmissions', {
|
||||
data: { type: 'betaAppReviewSubmissions', relationships: { build: { data: { type: 'builds', id: build.id } } } },
|
||||
});
|
||||
if (!submit.ok) fail('Submitting for beta review', submit);
|
||||
console.log('Submitted for beta review.');
|
||||
} else {
|
||||
console.log(`Not submitted again: the build is ${external}.`);
|
||||
}
|
||||
|
||||
const link = group.attributes.publicLinkEnabled ? group.attributes.publicLink : null;
|
||||
console.log(
|
||||
link
|
||||
? `Testers with ${link} get it once Apple approves the build.`
|
||||
: `The "${groupName}" group has no public link enabled.`,
|
||||
);
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
getMaxObjectsInGet: vi.fn(() => 500),
|
||||
getMaxObjectsInSet: vi.fn(() => 500),
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { saveDraftVersion, type OutgoingAttachment, type OutgoingEmail } from '../email';
|
||||
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
/**
|
||||
* A tiny server with Stalwart's blob semantics, as measured on 0.16.22 (2026-10-14):
|
||||
* an email's part blobIds are derived from that email and stop resolving the
|
||||
* moment it is destroyed ("blobId … does not exist on this server"); creating
|
||||
* an email copies the referenced bytes into new parts with new blobIds.
|
||||
*/
|
||||
function fakeStalwart() {
|
||||
let seq = 0;
|
||||
const emails = new Map<string, Array<{ blobId: string; name: string; size: number; cid?: string; type: string }>>();
|
||||
const live = (blobId: string) => [...emails.values()].some((parts) => parts.some((p) => p.blobId === blobId));
|
||||
const seed = (id: string, parts: OutgoingAttachment[]) => {
|
||||
emails.set(id, parts.map((p) => ({ blobId: p.blobId, name: p.name, size: p.size ?? 0, cid: p.cid, type: p.type })));
|
||||
};
|
||||
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, any>, string]>) => {
|
||||
const out: Array<[string, unknown, string]> = [];
|
||||
let createdId: string | undefined;
|
||||
for (const [method, args, callId] of calls) {
|
||||
if (method === 'Email/set' && args.create) {
|
||||
const create = args.create.draft;
|
||||
const dead = (create.attachments ?? []).find((a: { blobId: string }) => !live(a.blobId));
|
||||
if (dead) {
|
||||
out.push(['Email/set', { notCreated: { draft: {
|
||||
type: 'blobNotFound', description: `blobId ${dead.blobId} does not exist on this server.`,
|
||||
} } }, callId]);
|
||||
continue;
|
||||
}
|
||||
createdId = `e${++seq}`;
|
||||
emails.set(createdId, (create.attachments ?? []).map((a: OutgoingAttachment, i: number) => ({
|
||||
blobId: `${createdId}-part${i}`, name: a.name, size: a.size ?? 0, cid: a.cid ? `<${a.cid}>` : undefined, type: a.type,
|
||||
})));
|
||||
out.push(['Email/set', { created: { draft: { id: createdId } } }, callId]);
|
||||
} else if (method === 'Email/set' && args.destroy) {
|
||||
for (const id of args.destroy) emails.delete(id);
|
||||
out.push(['Email/set', { destroyed: args.destroy }, callId]);
|
||||
} else if (method === 'Email/get') {
|
||||
if (!createdId || args.ids?.[0] !== '#draft') {
|
||||
out.push(['error', { type: 'invalidResultReference' }, callId]);
|
||||
continue;
|
||||
}
|
||||
out.push(['Email/get', { list: [{ id: createdId, attachments: emails.get(createdId) }] }, callId]);
|
||||
}
|
||||
}
|
||||
return { methodResponses: out };
|
||||
});
|
||||
return { seed, emails };
|
||||
}
|
||||
|
||||
const IMG_A: OutgoingAttachment = { blobId: 'd0-part-a', name: 'schema-a.png', size: 136574, type: 'image/png', disposition: 'inline', cid: 'a@undust.co' };
|
||||
const IMG_B: OutgoingAttachment = { blobId: 'd0-part-b', name: 'schema-b.png', size: 175155, type: 'image/png', disposition: 'inline', cid: 'b@undust.co' };
|
||||
|
||||
const draftWith = (attachments: OutgoingAttachment[]): OutgoingEmail => ({
|
||||
from: [{ email: 'me@undust.co' }],
|
||||
to: [{ email: 'test@example.invalid' }],
|
||||
subject: 'RE: draft with two inline images',
|
||||
htmlBody: '<p>x <img src="cid:a@undust.co"> <img src="cid:b@undust.co"></p>',
|
||||
textBody: 'x',
|
||||
attachments,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('saveDraftVersion', () => {
|
||||
it('reads the new version\'s parts back in the same request, by creation id', async () => {
|
||||
fakeStalwart().seed('d0', [IMG_A]);
|
||||
const saved = await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
|
||||
const calls = mockRequest.mock.calls[0][0];
|
||||
expect(calls.map((c: [string]) => c[0])).toEqual(['Email/set', 'Email/get']);
|
||||
expect(calls[1][1].ids).toEqual(['#draft']);
|
||||
expect(saved.id).toBe('e1');
|
||||
expect(saved.attachments.map((a) => a.blobId)).toEqual(['e1-part0']);
|
||||
});
|
||||
|
||||
it('destroys the previous version only after the new one exists', async () => {
|
||||
fakeStalwart().seed('d0', [IMG_A]);
|
||||
await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
|
||||
const destroyCall = mockRequest.mock.calls[1][0][0];
|
||||
expect(destroyCall[0]).toBe('Email/set');
|
||||
expect(destroyCall[1].destroy).toEqual(['d0']);
|
||||
});
|
||||
|
||||
it('still returns the id when the read-back fails', async () => {
|
||||
mockRequest.mockResolvedValueOnce({ methodResponses: [
|
||||
['Email/set', { created: { draft: { id: 'e9' } } }, '0'],
|
||||
['error', { type: 'serverFail' }, '1'],
|
||||
] });
|
||||
const saved = await saveDraftVersion(draftWith([]), 'drafts');
|
||||
expect(saved).toEqual({ id: 'e9', attachments: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-opened draft with inline images: save, save again, send', () => {
|
||||
// What ComposeScreen does: buildOutgoing maps each held blobId through the
|
||||
// state, saveDraftOnce rebases it on the parts read back.
|
||||
async function editSession(rebase: boolean) {
|
||||
const server = fakeStalwart();
|
||||
server.seed('d0', [IMG_A, IMG_B]); // the draft as deposited (IMAP APPEND or an earlier save)
|
||||
let state = initialDraftPartBlobState();
|
||||
let draftId = 'd0';
|
||||
const outgoing = () => draftWith([IMG_A, IMG_B].map((a) => ({ ...a, blobId: liveBlobId(state, a.blobId) })));
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sent = outgoing();
|
||||
const saved = await saveDraftVersion(sent, 'drafts', draftId);
|
||||
draftId = saved.id;
|
||||
if (rebase) state = rebaseDraftPartBlobs(state, sent.attachments ?? [], saved.attachments);
|
||||
}
|
||||
return { server, draftId };
|
||||
}
|
||||
|
||||
it('reproduces the bug without the rebase: the second save references the destroyed draft\'s parts', async () => {
|
||||
await expect(editSession(false)).rejects.toThrow('blobId d0-part-a does not exist on this server.');
|
||||
});
|
||||
|
||||
it('keeps every save (and so the send, which builds the same attachments) on live blobs', async () => {
|
||||
const { server, draftId } = await editSession(true);
|
||||
expect([...server.emails.keys()]).toEqual([draftId]);
|
||||
expect(server.emails.get(draftId)!.map((p) => p.name)).toEqual(['schema-a.png', 'schema-b.png']);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
getFullEmails,
|
||||
getThreadsHeaders,
|
||||
createDraft,
|
||||
saveDraftVersion,
|
||||
destroyEmails,
|
||||
sendEmail,
|
||||
markAsSpam,
|
||||
undoSpam,
|
||||
@@ -33,6 +35,9 @@ import {
|
||||
EMAIL_FULL_PROPERTIES,
|
||||
} from '../email';
|
||||
import { getIdentities } from '../identity';
|
||||
import { uploadBytes } from '../blob';
|
||||
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
|
||||
import type { OutgoingAttachment } from '../email';
|
||||
import { getCalendars, queryEvents, createEvent, deleteEvents } from '../calendar';
|
||||
import { getAddressBooks, createContact, deleteContacts } from '../contacts';
|
||||
|
||||
@@ -192,6 +197,44 @@ live('live Stalwart', () => {
|
||||
await destroyEmails([id]);
|
||||
}, 60_000);
|
||||
|
||||
it('keeps a re-opened draft with an inline image saveable across saves (#849 part blobs)', async () => {
|
||||
// A 1x1 PNG uploaded once; D0 stands for the draft as it sits on the server.
|
||||
const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0));
|
||||
const up = await uploadBytes(png, 'image/png');
|
||||
const base = {
|
||||
from: [{ email: USER }],
|
||||
to: [{ email: 'test@example.invalid' }],
|
||||
subject: '[TEST bulwark-blob — supprimable]',
|
||||
htmlBody: '<p>live <img src="cid:img1@live.test"></p>',
|
||||
textBody: 'live',
|
||||
};
|
||||
const img = (blobId: string): OutgoingAttachment => ({
|
||||
blobId, type: 'image/png', name: 'img1.png', size: png.byteLength, disposition: 'inline', cid: 'img1@live.test',
|
||||
});
|
||||
const d0 = await saveDraftVersion({ ...base, attachments: [img(up.blobId)] }, drafts);
|
||||
const created = [d0.id];
|
||||
try {
|
||||
// Re-open D0: the composer holds D0's part blobId.
|
||||
const seeded = d0.attachments[0].blobId;
|
||||
let state = initialDraftPartBlobState();
|
||||
let draftId = d0.id;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sent = [img(liveBlobId(state, seeded))];
|
||||
const saved = await saveDraftVersion({ ...base, attachments: sent }, drafts, draftId);
|
||||
created.push(saved.id);
|
||||
draftId = saved.id;
|
||||
state = rebaseDraftPartBlobs(state, sent, saved.attachments);
|
||||
}
|
||||
// Without the rebase the seeded part blob is gone with D0.
|
||||
await expect(saveDraftVersion({ ...base, attachments: [img(seeded)] }, drafts))
|
||||
.rejects.toThrow(/does not exist|blobNotFound/);
|
||||
const [last] = await getFullEmails([draftId]);
|
||||
expect(last.attachments?.[0]?.cid).toContain('img1@live.test');
|
||||
} finally {
|
||||
await destroyEmails(created).catch(() => undefined);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
it('sends through Drafts and files into Sent only after submission (#188)', async () => {
|
||||
const identities = await getIdentities();
|
||||
expect(identities.length).toBeGreaterThan(0);
|
||||
|
||||
+48
-5
@@ -1679,31 +1679,64 @@ export async function sendEmail(
|
||||
};
|
||||
}
|
||||
|
||||
/** A saved draft version and the attachment parts the server built for it. */
|
||||
export interface SavedDraftVersion {
|
||||
id: string;
|
||||
/**
|
||||
* Attachment and inline parts of the new version, with their blobIds. A
|
||||
* part's blobId only lives as long as this version: re-opened drafts must
|
||||
* send these (not the previous version's) on their next save or send.
|
||||
* Empty when the server did not answer the read-back.
|
||||
*/
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
const DRAFT_PART_BODY_PROPERTIES = ['partId', 'blobId', 'size', 'name', 'type', 'cid', 'disposition'];
|
||||
|
||||
/**
|
||||
* Save a draft into the Drafts folder (`$draft` + `$seen`). When `previousDraftId`
|
||||
* is given it is destroyed only AFTER the replacement was created (#849): a
|
||||
* combined create+destroy would delete the last good copy when the create
|
||||
* fails (e.g. blobNotFound on a re-opened draft's attachments). Returns the
|
||||
* new draft id.
|
||||
* fails (e.g. blobNotFound on a re-opened draft's attachments). The new
|
||||
* version's attachment parts are read back in the same request, so the
|
||||
* composer can re-point its draft-part blobIds before the old version (and
|
||||
* its part blobs) is destroyed.
|
||||
*/
|
||||
export async function createDraft(
|
||||
export async function saveDraftVersion(
|
||||
email: OutgoingEmail,
|
||||
draftsMailboxId: string,
|
||||
previousDraftId?: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
): Promise<SavedDraftVersion> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const emailCreate = buildEmailCreate(email);
|
||||
emailCreate.mailboxIds = { [draftsMailboxId]: true };
|
||||
emailCreate.keywords = { $seen: true, $draft: true };
|
||||
const res = await jmapClient.request([
|
||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
||||
// `#draft` is the creation id (RFC 8620 §5.3). Stalwart 0.16 does not
|
||||
// resolve a result reference into an Email/set `created` map
|
||||
// (invalidResultReference), but it does resolve the creation id.
|
||||
['Email/get', {
|
||||
accountId,
|
||||
ids: ['#draft'],
|
||||
properties: ['id', 'attachments'],
|
||||
bodyProperties: DRAFT_PART_BODY_PROPERTIES,
|
||||
}, '1'],
|
||||
]);
|
||||
const body = requireMethodResult(res, '0', 'Email/set');
|
||||
const notCreated = body.notCreated?.draft as { description?: string; type?: string } | undefined;
|
||||
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to save draft');
|
||||
const id = body.created?.draft?.id as string | undefined;
|
||||
if (!id) throw new Error('Draft save returned no id');
|
||||
let attachments: Attachment[] = [];
|
||||
try {
|
||||
const got = requireMethodResult(res, '1', 'Email/get');
|
||||
const saved = (got.list as Array<{ id: string; attachments?: Attachment[] }> | undefined)?.find((e) => e.id === id);
|
||||
attachments = (saved?.attachments ?? []).filter((a) => !!a.blobId);
|
||||
} catch (err) {
|
||||
console.warn('[email] could not read back the saved draft parts:', err);
|
||||
}
|
||||
if (previousDraftId && previousDraftId !== id) {
|
||||
try {
|
||||
await destroyEmails([previousDraftId], accountId);
|
||||
@@ -1711,7 +1744,17 @@ export async function createDraft(
|
||||
console.warn('[email] failed to destroy previous draft version:', err);
|
||||
}
|
||||
}
|
||||
return id;
|
||||
return { id, attachments };
|
||||
}
|
||||
|
||||
/** {@link saveDraftVersion} for callers that only need the new draft id. */
|
||||
export async function createDraft(
|
||||
email: OutgoingEmail,
|
||||
draftsMailboxId: string,
|
||||
previousDraftId?: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
return (await saveDraftVersion(email, draftsMailboxId, previousDraftId, accountIdOverride)).id;
|
||||
}
|
||||
|
||||
export interface ScheduledEmail {
|
||||
|
||||
+17
-11
@@ -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
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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';
|
||||
@@ -12,7 +12,8 @@ import { bodyDocument } from '../lib/email-body-document';
|
||||
import { estimateBodyHeight, lastBodyHeight, rememberBodyHeight } from '../lib/body-heights';
|
||||
import { parseMailtoUrl } from '../lib/unsubscribe';
|
||||
import { fetchInlineImageDataUri } from '../lib/email-export';
|
||||
import { isSenderContentTrusted } from '../lib/trusted-senders';
|
||||
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
||||
import { useHasContacts } from '../lib/capabilities';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
@@ -527,6 +528,8 @@ export default function EmailBodyView({
|
||||
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
const hasContacts = useHasContacts();
|
||||
const syncTrustedSenders = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
|
||||
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
||||
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
||||
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
|
||||
@@ -540,8 +543,8 @@ export default function EmailBodyView({
|
||||
// entries (synced across devices) feed the external-content trust check. We
|
||||
// do not create the book here — it is created lazily when a sender is trusted.
|
||||
React.useEffect(() => {
|
||||
if (trustedSendersAddressBook && !trustedSendersLoaded) void loadTrustedSendersBook(false);
|
||||
}, [trustedSendersAddressBook, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||
if (syncTrustedSenders && !trustedSendersLoaded) void loadTrustedSendersBook(false);
|
||||
}, [syncTrustedSenders, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||
// Most marketing email is authored against a white background, so dark-mode
|
||||
// inversion can wreck logos/banners. With this flag the user opts to render
|
||||
// emails on a light surface even while the rest of the app is dark; the
|
||||
@@ -561,7 +564,7 @@ export default function EmailBodyView({
|
||||
const text = picked.text;
|
||||
const trusted = isSenderContentTrusted(senderEmail, {
|
||||
isLocallyTrusted: isSenderTrusted,
|
||||
syncEnabled: trustedSendersAddressBook,
|
||||
syncEnabled: syncTrustedSenders,
|
||||
trustedBookEmails: trustedSenderEmails,
|
||||
});
|
||||
|
||||
@@ -721,12 +724,11 @@ export default function EmailBodyView({
|
||||
const onLoadImages = () => setAllowOnce(true);
|
||||
const onTrustSender = () => {
|
||||
if (senderEmail) {
|
||||
// Keep the local allow-list for instant effect. The server-side "Trusted
|
||||
// Senders" address book (synced across devices) is only written when
|
||||
// the user opted into it - otherwise a book would silently appear in
|
||||
// their contacts (matches the webmail).
|
||||
// Keep the local allow-list for instant effect, and file the sender in
|
||||
// the server-side "Trusted Senders" address book (synced across
|
||||
// devices) unless the user turned that off.
|
||||
addTrustedSender(senderEmail);
|
||||
if (trustedSendersAddressBook) {
|
||||
if (syncTrustedSenders) {
|
||||
void addToTrustedSendersBook(senderEmail).catch(() => {});
|
||||
}
|
||||
setAllowOnce(true);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
@@ -76,10 +78,16 @@ interface Props {
|
||||
onSelectionChange?: (state: RichTextSelectionState) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
/**
|
||||
* Android: the page undid a caret-reveal pan of `dy` px (dp) inside the
|
||||
* WebView. The host should scroll its ScrollView down by `dy` so the caret
|
||||
* stays visible (see `pinVisualViewport` in editor-html).
|
||||
*/
|
||||
onViewportShift?: (dy: number) => void;
|
||||
}
|
||||
|
||||
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
|
||||
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur },
|
||||
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift },
|
||||
ref,
|
||||
) {
|
||||
const c = useColors();
|
||||
@@ -90,6 +98,8 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
const onSelectionRef = React.useRef(onSelectionChange);
|
||||
const onFocusRef = React.useRef(onFocus);
|
||||
const onBlurRef = React.useRef(onBlur);
|
||||
const onViewportShiftRef = React.useRef(onViewportShift);
|
||||
onViewportShiftRef.current = onViewportShift;
|
||||
onChangeRef.current = onChange;
|
||||
onSelectionRef.current = onSelectionChange;
|
||||
onFocusRef.current = onFocus;
|
||||
@@ -98,7 +108,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
// Build the source HTML once. Theming changes won't auto-rebuild the page,
|
||||
// which is fine: themes change rarely and the user can recompose if needed.
|
||||
const html = React.useMemo(
|
||||
() => buildEditorHtml({ initialHtml, placeholder, c, blockRemoteImages }),
|
||||
() => buildEditorHtml({
|
||||
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
|
||||
}),
|
||||
// c reference changes per theme switch but we don't want to lose user
|
||||
// edits on a theme change. Bake in the initial colors only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -176,6 +188,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) => {
|
||||
@@ -219,6 +234,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
case 'blur':
|
||||
onBlurRef.current?.();
|
||||
break;
|
||||
case 'viewportShift': {
|
||||
const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload);
|
||||
if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+447
-393
@@ -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,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;
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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')}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,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': [],
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
type LayoutChangeEvent,
|
||||
type ViewToken,
|
||||
} from 'react-native';
|
||||
import { format, type Locale } from 'date-fns';
|
||||
import { displayNow, isDisplayToday, isDisplayTomorrow } from '../../lib/calendar-timezone';
|
||||
import { endOfWeek, format, startOfWeek, type Locale } from 'date-fns';
|
||||
import { displayNow, isDisplayToday } from '../../lib/calendar-timezone';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
@@ -47,25 +47,28 @@ interface AgendaViewProps {
|
||||
/** Reports the first day in view as the user scrolls. */
|
||||
onVisibleDateChange?: (date: Date) => void;
|
||||
timeFormat?: TimeFormat;
|
||||
weekStartsOn?: 0 | 1 | 6;
|
||||
/** The user's addresses, to draw events they declined as inactive. */
|
||||
currentUserEmails?: string[];
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
/** Tapping an empty "today" row creates an event on that day. */
|
||||
onCreateAt?: (date: Date) => void;
|
||||
}
|
||||
|
||||
interface DaySection extends AgendaDay {
|
||||
key: string;
|
||||
title: string;
|
||||
/** Set on the first day of a month in the list: the month's name. */
|
||||
monthTitle: string | null;
|
||||
/** Set on the first day of a week in the list: the week's range. */
|
||||
weekTitle: string | null;
|
||||
}
|
||||
|
||||
function formatDayHeader(
|
||||
date: Date,
|
||||
t: (key: string, fallback?: string) => string,
|
||||
locale: Locale,
|
||||
): string {
|
||||
// Today and tomorrow on a clock in the calendar's time zone.
|
||||
if (isDisplayToday(date)) return t('calendar.events.today_header', 'Today');
|
||||
if (isDisplayTomorrow(date)) return t('calendar.events.tomorrow_header', 'Tomorrow');
|
||||
return format(date, 'EEEE, MMM d', { locale });
|
||||
// The week separators: "Oct 11 – 17", or "Sep 27 – Oct 3" across months.
|
||||
function formatWeekRange(date: Date, weekStartsOn: 0 | 1 | 6, locale: Locale): string {
|
||||
const start = startOfWeek(date, { weekStartsOn });
|
||||
const end = endOfWeek(date, { weekStartsOn });
|
||||
const sameMonth = start.getMonth() === end.getMonth();
|
||||
return `${format(start, 'MMM d', { locale })} – ${format(end, sameMonth ? 'd' : 'MMM d', { locale })}`;
|
||||
}
|
||||
|
||||
const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 1 };
|
||||
@@ -92,8 +95,10 @@ export function AgendaView({
|
||||
isLoading = false,
|
||||
onVisibleDateChange,
|
||||
timeFormat,
|
||||
weekStartsOn = 1,
|
||||
currentUserEmails,
|
||||
onSelectEvent,
|
||||
onCreateAt,
|
||||
}: AgendaViewProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -104,15 +109,21 @@ export function AgendaView({
|
||||
[eventsByDay, events],
|
||||
);
|
||||
|
||||
const sections = React.useMemo<DaySection[]>(
|
||||
() =>
|
||||
buildAgendaDays(index, loaded, displayNow()).map((day) => ({
|
||||
...day,
|
||||
key: dayKey(day.date),
|
||||
title: formatDayHeader(day.date, t, locale),
|
||||
})),
|
||||
[index, loaded, t, locale],
|
||||
);
|
||||
// A heading where a month starts and a separator where a week starts,
|
||||
// then the days.
|
||||
const sections = React.useMemo<DaySection[]>(() => {
|
||||
let lastMonth = '';
|
||||
let lastWeek = '';
|
||||
return buildAgendaDays(index, loaded, displayNow()).map((day) => {
|
||||
const month = format(day.date, 'yyyy-MM');
|
||||
const week = dayKey(startOfWeek(day.date, { weekStartsOn }));
|
||||
const monthTitle = month !== lastMonth ? format(day.date, 'LLLL yyyy', { locale }) : null;
|
||||
const weekTitle = week !== lastWeek ? formatWeekRange(day.date, weekStartsOn, locale) : null;
|
||||
lastMonth = month;
|
||||
lastWeek = week;
|
||||
return { ...day, key: dayKey(day.date), monthTitle, weekTitle };
|
||||
});
|
||||
}, [index, loaded, weekStartsOn, locale]);
|
||||
|
||||
const listRef = React.useRef<SectionList<CalendarEvent, DaySection>>(null);
|
||||
const sectionsRef = React.useRef(sections);
|
||||
@@ -297,7 +308,7 @@ export function AgendaView({
|
||||
ref={listRef}
|
||||
sections={sections}
|
||||
keyExtractor={(item, i) => `${item.id}:${i}`}
|
||||
stickySectionHeadersEnabled
|
||||
stickySectionHeadersEnabled={false}
|
||||
// Earlier days are inserted above what the user is reading without
|
||||
// moving it.
|
||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||
@@ -314,32 +325,42 @@ export function AgendaView({
|
||||
initialNumToRender={12}
|
||||
maxToRenderPerBatch={12}
|
||||
windowSize={11}
|
||||
renderSectionHeader={({ section }) => {
|
||||
const today = isDisplayToday(section.date);
|
||||
return (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, today && styles.sectionTitleToday]}>
|
||||
{section.title}
|
||||
</Text>
|
||||
<Text style={styles.sectionSub}>{format(section.date, 'PP', { locale })}</Text>
|
||||
renderSectionHeader={({ section }) =>
|
||||
section.monthTitle || section.weekTitle ? (
|
||||
<View>
|
||||
{section.monthTitle && <Text style={styles.monthTitle}>{section.monthTitle}</Text>}
|
||||
{section.weekTitle && <Text style={styles.weekTitle}>{section.weekTitle}</Text>}
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
renderItem={({ item, index: i, section }) => (
|
||||
<View style={[styles.row, i === section.data.length - 1 && styles.rowLast]}>
|
||||
<DateColumn date={section.date} show={i === 0} locale={locale} styles={styles} />
|
||||
<View style={styles.eventCol}>
|
||||
<EventCard
|
||||
event={item}
|
||||
calendars={calendars}
|
||||
timeFormat={timeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
onPress={onSelectEvent}
|
||||
day={section.date}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.itemWrap}>
|
||||
<EventCard
|
||||
event={item}
|
||||
calendars={calendars}
|
||||
timeFormat={timeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
onPress={onSelectEvent}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
renderSectionFooter={({ section }) =>
|
||||
section.data.length === 0 ? (
|
||||
<View style={styles.emptyDayWrap}>
|
||||
<Text style={styles.emptyDayText}>{t('calendar.events.no_events', 'No events')}</Text>
|
||||
<View style={[styles.row, styles.rowLast]}>
|
||||
<DateColumn date={section.date} show locale={locale} styles={styles} />
|
||||
<Pressable
|
||||
style={styles.emptyDay}
|
||||
onPress={onCreateAt ? () => onCreateAt(section.date) : undefined}
|
||||
accessibilityRole={onCreateAt ? 'button' : undefined}
|
||||
>
|
||||
<Text style={styles.emptyDayText}>
|
||||
{t('calendar.events.nothing_planned', 'Nothing planned. Tap to create.')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
@@ -347,24 +368,79 @@ export function AgendaView({
|
||||
);
|
||||
}
|
||||
|
||||
type AgendaStyles = ReturnType<typeof makeStyles>;
|
||||
|
||||
// The day on the left of its first row: weekday over the day number, today
|
||||
// in a filled circle.
|
||||
function DateColumn({
|
||||
date,
|
||||
show,
|
||||
locale,
|
||||
styles,
|
||||
}: {
|
||||
date: Date;
|
||||
show: boolean;
|
||||
locale: Locale;
|
||||
styles: AgendaStyles;
|
||||
}) {
|
||||
if (!show) return <View style={styles.dateCol} />;
|
||||
const today = isDisplayToday(date);
|
||||
return (
|
||||
<View style={styles.dateCol}>
|
||||
<Text style={[styles.dateWeekday, today && styles.dateWeekdayToday]}>
|
||||
{format(date, 'EEE', { locale })}
|
||||
</Text>
|
||||
<View style={[styles.dateNumber, today && styles.dateNumberToday]}>
|
||||
<Text style={[styles.dateNumberText, today && styles.dateNumberTextToday]}>
|
||||
{format(date, 'd')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const DATE_COL_WIDTH = 52;
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
gap: spacing.sm,
|
||||
monthTitle: {
|
||||
...typography.h2,
|
||||
fontWeight: '500',
|
||||
color: c.text,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
paddingTop: spacing.xxl,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
sectionTitle: { ...typography.bodyMedium, color: c.text },
|
||||
sectionTitleToday: { color: c.primary },
|
||||
sectionSub: { ...typography.caption, color: c.textMuted },
|
||||
itemWrap: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
|
||||
emptyDayWrap: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
|
||||
emptyDayText: { ...typography.caption, color: c.textMuted, textAlign: 'center' },
|
||||
weekTitle: {
|
||||
...typography.caption,
|
||||
color: c.textMuted,
|
||||
paddingLeft: DATE_COL_WIDTH + spacing.sm,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
paddingRight: spacing.lg,
|
||||
paddingLeft: spacing.sm,
|
||||
},
|
||||
rowLast: { paddingBottom: spacing.sm },
|
||||
dateCol: { width: DATE_COL_WIDTH, alignItems: 'center' },
|
||||
dateWeekday: { ...typography.captionMedium, color: c.textSecondary },
|
||||
dateWeekdayToday: { color: c.primary },
|
||||
dateNumber: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dateNumberToday: { backgroundColor: c.primary },
|
||||
dateNumberText: { fontSize: 20, lineHeight: 26, fontWeight: '500', color: c.text },
|
||||
dateNumberTextToday: { color: c.primaryForeground },
|
||||
eventCol: { flex: 1, marginLeft: spacing.md },
|
||||
emptyDay: { flex: 1, marginLeft: spacing.md, paddingTop: 18 },
|
||||
emptyDayText: { ...typography.body, color: c.textSecondary },
|
||||
emptyWrap: { paddingVertical: spacing.xl, alignItems: 'center' },
|
||||
edge: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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 '../icons';
|
||||
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useAnimDuration } from '../../theme/dynamic';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
|
||||
interface CalendarFabProps {
|
||||
onNewEvent: () => void;
|
||||
/** With tasks turned on, the button first offers "Event" and "Task". */
|
||||
onNewTask?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The floating create button in the corner of the calendar, styled like the
|
||||
* mail list's compose button. Without tasks it creates an event at once;
|
||||
* with tasks it opens a small menu over a scrim.
|
||||
*/
|
||||
export function CalendarFab({ onNewEvent, onNewTask }: CalendarFabProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const progress = React.useRef(new Animated.Value(0)).current;
|
||||
const duration = useAnimDuration(160);
|
||||
|
||||
React.useEffect(() => {
|
||||
Animated.timing(progress, {
|
||||
toValue: open ? 1 : 0,
|
||||
duration,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [open, progress, duration]);
|
||||
|
||||
const pick = (action: () => void) => {
|
||||
setOpen(false);
|
||||
action();
|
||||
};
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const onMainPress = () => {
|
||||
if (!onNewTask) {
|
||||
onNewEvent();
|
||||
return;
|
||||
}
|
||||
if (open) pick(onNewEvent);
|
||||
else setOpen(true);
|
||||
};
|
||||
|
||||
const rotate = progress.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '90deg'] });
|
||||
const lift = progress.interpolate({ inputRange: [0, 1], outputRange: [12, 0] });
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && (
|
||||
<Animated.View style={[styles.scrim, { opacity: progress }]}>
|
||||
<Pressable
|
||||
style={StyleSheet.absoluteFill}
|
||||
onPress={() => setOpen(false)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.close', 'Close')}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
{/* 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>
|
||||
<Pressable
|
||||
style={styles.miniFab}
|
||||
onPress={() => pick(onNewTask)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('calendar.events.new_task', 'New task')}
|
||||
>
|
||||
<CircleCheck size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
)}
|
||||
<View style={styles.mainRow}>
|
||||
{open && (
|
||||
<Animated.Text style={[styles.optionLabel, { opacity: progress }]}>
|
||||
{t('calendar.fab.event', 'Event')}
|
||||
</Animated.Text>
|
||||
)}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
|
||||
onPress={onMainPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('calendar.events.new_event', 'New event')}
|
||||
>
|
||||
{open ? (
|
||||
<CalendarPlus size={24} color={c.background} />
|
||||
) : (
|
||||
<Animated.View style={{ transform: [{ rotate }] }}>
|
||||
<Plus size={26} color={c.background} />
|
||||
</Animated.View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
scrim: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
zIndex: 20,
|
||||
},
|
||||
anchor: {
|
||||
position: 'absolute',
|
||||
right: spacing.lg,
|
||||
bottom: spacing.lg,
|
||||
alignItems: 'flex-end',
|
||||
gap: spacing.lg,
|
||||
zIndex: 21,
|
||||
},
|
||||
option: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, marginRight: 8 },
|
||||
mainRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
|
||||
optionLabel: { ...typography.baseMedium, color: '#ffffff' },
|
||||
miniFab: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: c.surface,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 3,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
},
|
||||
// Same as the compose button on the mail list (EmailListScreen).
|
||||
fab: {
|
||||
width: componentSizes.fab,
|
||||
height: componentSizes.fab,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: c.text,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 6,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: { width: 0, height: 3 },
|
||||
},
|
||||
fabPressed: { opacity: 0.85 },
|
||||
});
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2,
|
||||
} from 'lucide-react-native';
|
||||
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, RefreshCw, ArrowUp, ArrowDown,
|
||||
type AppIcon,
|
||||
} from '../icons';
|
||||
import type { Calendar } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
@@ -22,9 +23,21 @@ 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: AppIcon;
|
||||
}
|
||||
|
||||
interface CalendarSidebarDrawerProps {
|
||||
visible: boolean;
|
||||
views?: DrawerViewOption[];
|
||||
activeView?: string;
|
||||
onSelectView?: (key: string) => void;
|
||||
onRefresh?: () => void;
|
||||
calendars: Calendar[];
|
||||
hiddenCalendarIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
@@ -42,12 +55,19 @@ interface CalendarSidebarDrawerProps {
|
||||
onShare?: (calendar: Calendar) => void;
|
||||
onClear?: (calendar: Calendar) => void;
|
||||
onDelete?: (calendar: Calendar) => void;
|
||||
// Move one of the user's own calendars next to `targetId`, a neighbour in
|
||||
// the same section (before it when moving up, after it when moving down).
|
||||
onMove?: (calendar: Calendar, targetId: string) => void;
|
||||
// Client-side iCal subscriptions are managed from the subscriptions sheet.
|
||||
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||
}
|
||||
|
||||
export function CalendarSidebarDrawer({
|
||||
visible,
|
||||
views,
|
||||
activeView,
|
||||
onSelectView,
|
||||
onRefresh,
|
||||
calendars,
|
||||
hiddenCalendarIds,
|
||||
onToggle,
|
||||
@@ -62,6 +82,7 @@ export function CalendarSidebarDrawer({
|
||||
onShare,
|
||||
onClear,
|
||||
onDelete,
|
||||
onMove,
|
||||
isSubscriptionCalendar,
|
||||
}: CalendarSidebarDrawerProps) {
|
||||
const c = useColors();
|
||||
@@ -131,11 +152,12 @@ export function CalendarSidebarDrawer({
|
||||
onShare,
|
||||
onClear,
|
||||
onDelete,
|
||||
onMove,
|
||||
isSubscriptionCalendar,
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
@@ -161,10 +183,44 @@ export function CalendarSidebarDrawer({
|
||||
>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{t('calendar.my_calendars', 'Calendars')}</Text>
|
||||
<Text style={styles.headerTitle}>{t('calendar.title', 'Calendar')}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
|
||||
{views && views.length > 0 && (
|
||||
<View style={styles.viewsSection}>
|
||||
{views.map(({ key, label, Icon }) => {
|
||||
const active = key === activeView;
|
||||
return (
|
||||
<Pressable
|
||||
key={key}
|
||||
onPress={() => onSelectView?.(key)}
|
||||
style={({ pressed }) => [
|
||||
styles.viewRow,
|
||||
active && styles.viewRowActive,
|
||||
pressed && !active && styles.rowPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: active }}
|
||||
>
|
||||
<Icon size={16} color={active ? c.text : c.textSecondary} />
|
||||
<Text style={[styles.viewText, active && styles.viewTextActive]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{onRefresh && (
|
||||
<Pressable
|
||||
onPress={onRefresh}
|
||||
style={({ pressed }) => [styles.viewRow, styles.refreshRow, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<RefreshCw size={16} color={c.textSecondary} />
|
||||
<Text style={styles.viewText}>{t('common.refresh', 'Refresh')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{calendars.length === 0 && (
|
||||
<Text style={styles.empty}>{t('calendar.drawer.no_calendars', 'No calendars yet.')}</Text>
|
||||
)}
|
||||
@@ -233,6 +289,7 @@ function Section({
|
||||
onShare,
|
||||
onClear,
|
||||
onDelete,
|
||||
onMove,
|
||||
isSubscriptionCalendar,
|
||||
}: {
|
||||
title: string;
|
||||
@@ -248,11 +305,14 @@ function Section({
|
||||
onShare?: (calendar: Calendar) => void;
|
||||
onClear?: (calendar: Calendar) => void;
|
||||
onDelete?: (calendar: Calendar) => void;
|
||||
onMove?: (calendar: Calendar, targetId: string) => void;
|
||||
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
// Neighbours a calendar can move past: the server calendars of this section.
|
||||
const orderable = calendars.filter((cal) => cal.id !== BIRTHDAY_CALENDAR_ID);
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
@@ -269,7 +329,11 @@ function Section({
|
||||
const canClear = !!onClear && isOwn && isWritableCalendar(cal);
|
||||
// Like webmail: the default calendar and shared calendars can't be deleted.
|
||||
const canDelete = !!onDelete && isOwn && !cal.isDefault && (!cal.myRights || cal.myRights.mayDelete !== false);
|
||||
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete;
|
||||
const orderIndex = orderable.indexOf(cal);
|
||||
const previousId = isOwn && orderIndex > 0 ? orderable[orderIndex - 1].id : undefined;
|
||||
const nextId = isOwn && orderIndex >= 0 ? orderable[orderIndex + 1]?.id : undefined;
|
||||
const canMove = !!onMove && (!!previousId || !!nextId);
|
||||
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete || canMove;
|
||||
const expanded = expandedId === cal.id && hasActions;
|
||||
return (
|
||||
<View key={cal.id}>
|
||||
@@ -304,6 +368,27 @@ function Section({
|
||||
|
||||
{expanded && (
|
||||
<View style={styles.actionsPanel}>
|
||||
{/* The panel stays open so a calendar can be moved several places. */}
|
||||
{canMove && previousId && (
|
||||
<Pressable
|
||||
onPress={() => onMove!(cal, previousId)}
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ArrowUp size={16} color={c.textSecondary} />
|
||||
<Text style={styles.panelRowText}>{t('settings.account.accounts.move_up', 'Move up')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{canMove && nextId && (
|
||||
<Pressable
|
||||
onPress={() => onMove!(cal, nextId)}
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ArrowDown size={16} color={c.textSecondary} />
|
||||
<Text style={styles.panelRowText}>{t('settings.account.accounts.move_down', 'Move down')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{canSetDefault && (
|
||||
<Pressable
|
||||
onPress={() => { onExpand(null); onSetDefault!(cal); }}
|
||||
@@ -431,6 +516,27 @@ function makeStyles(c: ThemePalette) {
|
||||
headerTitle: { ...typography.h3, color: c.text },
|
||||
|
||||
scroll: { flex: 1 },
|
||||
|
||||
viewsSection: {
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
// Same rows as the mail drawer's folders (SidebarDrawer).
|
||||
viewRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
minHeight: 44,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: 'transparent',
|
||||
},
|
||||
viewRowActive: { backgroundColor: c.accent, borderLeftColor: c.primary },
|
||||
refreshRow: { marginTop: spacing.xs },
|
||||
viewText: { ...typography.body, color: c.text },
|
||||
viewTextActive: { ...typography.bodySemibold },
|
||||
scrollContent: { paddingBottom: spacing.lg },
|
||||
|
||||
empty: {
|
||||
|
||||
@@ -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';
|
||||
@@ -8,6 +8,7 @@ import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
eventTimeRange,
|
||||
getEventColor,
|
||||
isTimedEventFullDayOnDate,
|
||||
timePattern,
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
@@ -22,6 +23,8 @@ interface EventCardProps {
|
||||
currentUserEmails?: string[];
|
||||
onPress?: (event: CalendarEvent) => void;
|
||||
onLongPress?: (event: CalendarEvent) => void;
|
||||
/** The day the row is listed under: a timed event filling it reads as all day. */
|
||||
day?: Date;
|
||||
}
|
||||
|
||||
function participantCount(event: CalendarEvent): number {
|
||||
@@ -33,12 +36,14 @@ function participantCount(event: CalendarEvent): number {
|
||||
* colour before the title, no card and no bar. Declined and cancelled events
|
||||
* get a hollow dot and a struck-through, muted title.
|
||||
*/
|
||||
export function EventCard({ event, calendars, timeFormat, currentUserEmails, onPress, onLongPress }: EventCardProps) {
|
||||
export function EventCard({ event, calendars, timeFormat, currentUserEmails, onPress, onLongPress, day }: EventCardProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const { locale, t } = useCalendarLocale();
|
||||
const color = getEventColor(event, calendars);
|
||||
const { start, end, allDay } = eventTimeRange(event);
|
||||
const range = eventTimeRange(event);
|
||||
const { start, end } = range;
|
||||
const allDay = range.allDay || (!!day && isTimedEventFullDayOnDate(event, day));
|
||||
const fmt = timePattern(timeFormat);
|
||||
const time = allDay
|
||||
? t('calendar.events.all_day', 'All day')
|
||||
@@ -59,7 +64,7 @@ export function EventCard({ event, calendars, timeFormat, currentUserEmails, onP
|
||||
<Text style={[styles.title, inactive && styles.titleInactive]} numberOfLines={1}>
|
||||
{event.title || t('calendar.events.no_title', '(No title)')}
|
||||
</Text>
|
||||
{event.showWithoutTime && (
|
||||
{allDay && (
|
||||
<View style={styles.allDayBadge}>
|
||||
<Text style={styles.allDayText}>{t('calendar.events.all_day', 'All day')}</Text>
|
||||
</View>
|
||||
@@ -70,7 +75,7 @@ export function EventCard({ event, calendars, timeFormat, currentUserEmails, onP
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{!event.showWithoutTime && (
|
||||
{!allDay && (
|
||||
<View style={styles.detailRow}>
|
||||
<Clock size={12} color={c.textMuted} />
|
||||
<Text style={styles.detailText}>{time}</Text>
|
||||
|
||||
@@ -4,14 +4,19 @@ import {
|
||||
Dimensions,
|
||||
Easing,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { openExternalUrl } from '../../lib/open-url';
|
||||
import { toast } from '../../stores/toast-store';
|
||||
import { ToastHost } from '../ToastHost';
|
||||
import { splitTextLinks } from '../../lib/linkify-text';
|
||||
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from '../../lib/event-links';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
AlignLeft,
|
||||
@@ -30,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';
|
||||
@@ -52,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';
|
||||
|
||||
@@ -88,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 =
|
||||
@@ -98,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 {
|
||||
@@ -135,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({
|
||||
@@ -184,8 +194,11 @@ export function EventDetailSheet({
|
||||
const recurrence = recurrenceLabel(event, t, locale);
|
||||
const reminders = alertsToReminders(event.alerts);
|
||||
const participants = getParticipantList(event, { resolveName: resolveContactName });
|
||||
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
|
||||
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
|
||||
const location = primaryLocationName(event);
|
||||
// Invitations (Teams above all) leave virtualLocations empty and bury the
|
||||
// join URL in the description; surface it as the meeting link anyway.
|
||||
const meeting = findMeetingLink(event);
|
||||
const videoUri = meeting?.uri;
|
||||
const isCancelled = event.status === 'cancelled';
|
||||
|
||||
// Can the signed-in user RSVP? Only when they appear as a non-organizer
|
||||
@@ -207,6 +220,23 @@ export function EventDetailSheet({
|
||||
);
|
||||
const myStatus = myParticipantId ? event.participants?.[myParticipantId]?.participationStatus : undefined;
|
||||
|
||||
const openLocation = () => {
|
||||
if (!location) return;
|
||||
const action = locationAction(location, meeting);
|
||||
if (action.kind === 'url') {
|
||||
void openExternalUrl(action.uri, { confirm: true });
|
||||
} else {
|
||||
void openExternalUrl(mapsUrl(action.query, Platform.OS));
|
||||
}
|
||||
};
|
||||
|
||||
const copyLocation = () => {
|
||||
if (!location) return;
|
||||
void Clipboard.setStringAsync(location).then(() => {
|
||||
toast.success(t('notifications.copied_to_clipboard', 'Copied to clipboard'));
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const doRsvp = async (status: RsvpStatus) => {
|
||||
if (!onRsvp || !myParticipantId || rsvpBusy) return;
|
||||
setRsvpBusy(true);
|
||||
@@ -218,7 +248,7 @@ export function EventDetailSheet({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
@@ -229,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}>
|
||||
@@ -247,7 +277,7 @@ export function EventDetailSheet({
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={styles.closeBtn}
|
||||
hitSlop={8}
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.close', 'Close')}
|
||||
>
|
||||
@@ -263,7 +293,21 @@ export function EventDetailSheet({
|
||||
>
|
||||
<DetailRow icon={<Clock size={16} color={c.textMuted} />} text={range} />
|
||||
{location ? (
|
||||
<DetailRow icon={<MapPin size={16} color={c.textMuted} />} text={location} />
|
||||
<View style={styles.detailRow}>
|
||||
<View style={styles.detailIcon}>
|
||||
<MapPin size={16} color={c.textMuted} />
|
||||
</View>
|
||||
<Text
|
||||
style={[styles.detailText, styles.detailLink]}
|
||||
numberOfLines={3}
|
||||
onPress={openLocation}
|
||||
onLongPress={copyLocation}
|
||||
accessibilityRole="link"
|
||||
accessibilityHint={t('calendar.detail.location_hint', 'Opens the location; long-press to copy it')}
|
||||
>
|
||||
{location}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{videoUri ? (
|
||||
<View style={styles.detailRow}>
|
||||
@@ -274,7 +318,10 @@ export function EventDetailSheet({
|
||||
style={styles.joinBtn}
|
||||
onPress={() => { void openExternalUrl(videoUri, { confirm: true }); }}
|
||||
>
|
||||
<Text style={styles.joinBtnText}>{t('calendar.detail.open_link', 'Open link')}</Text>
|
||||
<Text style={styles.joinBtnText}>
|
||||
{t('calendar.detail.open_link', 'Open link')}
|
||||
{meeting?.provider ? ` · ${meeting.provider}` : ''}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -414,6 +461,9 @@ export function EventDetailSheet({
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
{/* The screen's own toast host sits under this Modal; this one shows
|
||||
the copy confirmation above the sheet. */}
|
||||
<ToastHost />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -568,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,
|
||||
@@ -595,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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -99,9 +102,11 @@ interface EventModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// A time already on the hour or half hour (a tapped slot) stays as it is.
|
||||
function nextHalfHour(d: Date): Date {
|
||||
const date = new Date(d);
|
||||
const min = date.getMinutes();
|
||||
if (min % 30 === 0 && date.getSeconds() === 0 && date.getMilliseconds() === 0) return date;
|
||||
const add = min < 30 ? 30 - min : 60 - min;
|
||||
date.setMinutes(date.getMinutes() + add, 0, 0);
|
||||
return date;
|
||||
@@ -361,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);
|
||||
}
|
||||
@@ -408,7 +419,7 @@ export function EventModal({
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
presentationStyle="fullScreen"
|
||||
@@ -448,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}
|
||||
/>
|
||||
|
||||
@@ -504,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>
|
||||
|
||||
@@ -520,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
|
||||
@@ -540,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
|
||||
@@ -662,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) => (
|
||||
@@ -697,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>
|
||||
@@ -707,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"
|
||||
@@ -723,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}
|
||||
/>
|
||||
@@ -745,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>
|
||||
)}
|
||||
@@ -902,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 },
|
||||
@@ -919,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: {
|
||||
@@ -928,7 +935,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
customAmountInput: {
|
||||
...typography.body,
|
||||
@@ -964,7 +971,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.popover,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
popoverRow: {
|
||||
@@ -986,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,
|
||||
},
|
||||
@@ -998,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 },
|
||||
@@ -1019,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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
type ListRenderItemInfo,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from 'react-native';
|
||||
import { addMonths, startOfMonth } from 'date-fns';
|
||||
import type { Calendar } from '../../api/types';
|
||||
import type { ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import type { EventDayIndex } from '../../lib/calendar-utils';
|
||||
import { MONTH_ROW_HEIGHT, MonthView } from './MonthView';
|
||||
|
||||
type WeekStart = 0 | 1 | 6;
|
||||
|
||||
/** Months either side of the one the panel opens on. */
|
||||
const MONTHS_EACH_SIDE = 24;
|
||||
/** Six week rows plus the weekday initials. */
|
||||
const PANEL_HEIGHT = MONTH_ROW_HEIGHT * 6 + 22;
|
||||
|
||||
interface MonthDropdownProps {
|
||||
/** The month the panel opens on. */
|
||||
month: Date;
|
||||
selectedDate: Date;
|
||||
eventsByDay: EventDayIndex;
|
||||
calendars: Calendar[];
|
||||
weekStartsOn: WeekStart;
|
||||
/** Reports the month swiped to, so the title can follow it. */
|
||||
onMonthChange: (month: Date) => void;
|
||||
onSelectDate: (date: Date) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The month that drops down under the calendar title: swipe sideways
|
||||
* between months, tap a day to go there.
|
||||
*/
|
||||
export function MonthDropdown({
|
||||
month,
|
||||
selectedDate,
|
||||
eventsByDay,
|
||||
calendars,
|
||||
weekStartsOn,
|
||||
onMonthChange,
|
||||
onSelectDate,
|
||||
}: MonthDropdownProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
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);
|
||||
return Array.from({ length: MONTHS_EACH_SIDE * 2 + 1 }, (_, i) => addMonths(first, i));
|
||||
});
|
||||
const lastIndexRef = React.useRef(MONTHS_EACH_SIDE);
|
||||
|
||||
const handleMomentumEnd = React.useCallback(
|
||||
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const index = Math.round(e.nativeEvent.contentOffset.x / width);
|
||||
if (index === lastIndexRef.current || !months[index]) return;
|
||||
lastIndexRef.current = index;
|
||||
onMonthChange(months[index]);
|
||||
},
|
||||
[width, months, onMonthChange],
|
||||
);
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
({ item }: ListRenderItemInfo<Date>) => (
|
||||
<ScrollView
|
||||
style={{ width, height: panelHeight }}
|
||||
scrollEnabled={panelHeight < PANEL_HEIGHT}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<MonthView
|
||||
currentDate={item}
|
||||
selectedDate={selectedDate}
|
||||
events={[]}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
weekStartsOn={weekStartsOn}
|
||||
onSelectDate={onSelectDate}
|
||||
/>
|
||||
</ScrollView>
|
||||
),
|
||||
[width, panelHeight, selectedDate, eventsByDay, calendars, weekStartsOn, onSelectDate],
|
||||
);
|
||||
|
||||
const getItemLayout = React.useCallback(
|
||||
(_: ArrayLike<Date> | null | undefined, i: number) => ({ length: width, offset: width * i, index: i }),
|
||||
[width],
|
||||
);
|
||||
|
||||
return (
|
||||
<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={lastIndexRef.current}
|
||||
onMomentumScrollEnd={handleMomentumEnd}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
initialNumToRender={1}
|
||||
windowSize={3}
|
||||
style={{ height: panelHeight }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function monthKey(month: Date): string {
|
||||
return `${month.getFullYear()}-${month.getMonth()}`;
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
panel: {
|
||||
backgroundColor: c.background,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
paddingTop: 4,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type ListRenderItemInfo,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
@@ -17,6 +18,7 @@ import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window';
|
||||
import {
|
||||
dayIndexIn,
|
||||
monthFocusRow,
|
||||
monthRowGeometry,
|
||||
monthKeyOf,
|
||||
monthMask,
|
||||
sampledRow,
|
||||
@@ -26,8 +28,7 @@ import {
|
||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
import { displayNow } from '../../lib/calendar-timezone';
|
||||
import {
|
||||
MONTH_ROW_HEIGHT,
|
||||
MONTH_ROW_HEIGHT_CHIPS,
|
||||
MONTH_ROW_HEIGHT_FULL_MIN,
|
||||
MonthWeekRow,
|
||||
MonthWeekdayHeader,
|
||||
useMonthStyles,
|
||||
@@ -117,8 +118,16 @@ function MonthScrollViewInner({
|
||||
}, [window]);
|
||||
const weeksRef = React.useRef(weeks);
|
||||
weeksRef.current = weeks;
|
||||
const rowHeight = showTimeInMonthView ? MONTH_ROW_HEIGHT_CHIPS : MONTH_ROW_HEIGHT;
|
||||
const viewportHeight = rowHeight * VISIBLE_ROWS;
|
||||
// Six weeks fill the space under the weekday initials, like Google
|
||||
// Calendar's month; rows wait for that space to be measured.
|
||||
const [listHeight, setListHeight] = React.useState(0);
|
||||
const handleListLayout = React.useCallback((e: LayoutChangeEvent) => {
|
||||
const height = e.nativeEvent.layout.height;
|
||||
if (height > 0) setListHeight(height);
|
||||
}, []);
|
||||
// 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.
|
||||
@@ -127,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;
|
||||
@@ -151,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(() => {
|
||||
@@ -196,6 +220,7 @@ function MonthScrollViewInner({
|
||||
timeFormat={timeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
labelMonths
|
||||
variant="full"
|
||||
height={rowHeight}
|
||||
locale={locale}
|
||||
styles={styles}
|
||||
@@ -232,8 +257,10 @@ function MonthScrollViewInner({
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.grid}>
|
||||
<View style={styles.fullGrid}>
|
||||
<MonthWeekdayHeader weekStartsOn={weekStartsOn} showWeekNumbers={showWeekNumbers} styles={styles} />
|
||||
<View style={styles.fullRows} onLayout={handleListLayout}>
|
||||
{listHeight > 0 && (
|
||||
<FlatList
|
||||
key={rowHeight}
|
||||
ref={listRef}
|
||||
@@ -253,14 +280,17 @@ 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}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -270,3 +300,9 @@ export const MonthScrollView = React.memo(MonthScrollViewInner);
|
||||
function weekKey(days: Date[]): string {
|
||||
return dayKey(days[0]);
|
||||
}
|
||||
|
||||
function rowOfWeek(weeks: Date[][], key: string | null): number | null {
|
||||
if (!key) return null;
|
||||
const i = weeks.findIndex((days) => dayKey(days[0]) === key);
|
||||
return i >= 0 ? i : null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { View, Text, StyleSheet, Pressable, useWindowDimensions } from 'react-native';
|
||||
import {
|
||||
addDays,
|
||||
endOfMonth,
|
||||
@@ -19,11 +19,12 @@ import {
|
||||
eventsOnDayFromIndex,
|
||||
getEventColor,
|
||||
getEventStartDate,
|
||||
isTimedEventFullDayOnDate,
|
||||
timePattern,
|
||||
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';
|
||||
@@ -35,6 +36,10 @@ type WeekStart = 0 | 1 | 6;
|
||||
// scrolling month uses them as fixed row heights.
|
||||
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;
|
||||
|
||||
@@ -48,6 +53,8 @@ interface MonthViewProps {
|
||||
showWeekNumbers?: boolean;
|
||||
// Render compact event chips (title + start time) instead of dots (#666).
|
||||
showTimeInMonthView?: boolean;
|
||||
/** Fill the available height with the full month grid. */
|
||||
fill?: boolean;
|
||||
timeFormat?: TimeFormat;
|
||||
/** The user's addresses, to draw events they declined as inactive. */
|
||||
currentUserEmails?: string[];
|
||||
@@ -113,6 +120,12 @@ export interface MonthWeekRowProps {
|
||||
labelMonths?: boolean;
|
||||
/** Fixed row height (continuous scrolling); natural height otherwise. */
|
||||
height?: number;
|
||||
/**
|
||||
* 'full': the month fills the screen, with grid
|
||||
* lines and an event chip per event. 'compact': numbers with event dots
|
||||
* (the month under the title).
|
||||
*/
|
||||
variant?: 'compact' | 'full';
|
||||
locale: Locale;
|
||||
styles: MonthStyles;
|
||||
onSelectDate: (date: Date) => void;
|
||||
@@ -133,12 +146,38 @@ function MonthWeekRowInner({
|
||||
currentUserEmails,
|
||||
labelMonths = false,
|
||||
height,
|
||||
variant = 'compact',
|
||||
locale,
|
||||
styles,
|
||||
onSelectDate,
|
||||
onLongPressDate,
|
||||
}: MonthWeekRowProps) {
|
||||
const c = useColors();
|
||||
const { width } = useWindowDimensions();
|
||||
const chipTime = monthChipShowsTime(true, width / 7);
|
||||
if (variant === 'full') {
|
||||
return (
|
||||
<FullWeekRow
|
||||
days={days}
|
||||
activeMask={activeMask}
|
||||
todayIndex={todayIndex}
|
||||
index={index}
|
||||
calendars={calendars}
|
||||
weekStartsOn={weekStartsOn}
|
||||
showWeekNumbers={showWeekNumbers}
|
||||
showTimeInMonthView={showTimeInMonthView}
|
||||
timeFormat={timeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
labelMonths={labelMonths}
|
||||
height={height ?? MONTH_ROW_HEIGHT_FULL_MIN}
|
||||
locale={locale}
|
||||
styles={styles}
|
||||
colors={c}
|
||||
onSelectDate={onSelectDate}
|
||||
onLongPressDate={onLongPressDate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={[styles.weekRow, height !== undefined && { height, overflow: 'hidden' }]}>
|
||||
{showWeekNumbers && (
|
||||
@@ -221,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>
|
||||
@@ -243,6 +282,115 @@ function MonthWeekRowInner({
|
||||
|
||||
export const MonthWeekRow = React.memo(MonthWeekRowInner);
|
||||
|
||||
/** Day number area at the top of a full month cell. */
|
||||
const FULL_NUMBER_HEIGHT = 24;
|
||||
/** One event chip in a full month cell, with its gap. */
|
||||
const FULL_CHIP_HEIGHT = 16;
|
||||
const FULL_CHIP_GAP = 2;
|
||||
|
||||
/** Event chips that fit a full month cell of `height`, keeping a line for "+N" when needed. */
|
||||
export function fullCellChipCount(height: number, eventCount: number): number {
|
||||
const room = Math.max(0, Math.floor((height - FULL_NUMBER_HEIGHT - 2) / (FULL_CHIP_HEIGHT + FULL_CHIP_GAP)));
|
||||
if (eventCount <= room) return eventCount;
|
||||
return Math.max(0, room - 1);
|
||||
}
|
||||
|
||||
// A week of the full-screen month grid: thin cell lines, the day number
|
||||
// small at the top (today in a filled circle), then one filled chip per
|
||||
// event and "+N" for those that don't fit.
|
||||
function FullWeekRow({
|
||||
days,
|
||||
activeMask,
|
||||
todayIndex,
|
||||
index,
|
||||
calendars,
|
||||
weekStartsOn,
|
||||
showWeekNumbers,
|
||||
showTimeInMonthView,
|
||||
timeFormat,
|
||||
currentUserEmails,
|
||||
labelMonths,
|
||||
height,
|
||||
locale,
|
||||
styles,
|
||||
colors: c,
|
||||
onSelectDate,
|
||||
onLongPressDate,
|
||||
}: Omit<MonthWeekRowProps, 'selectedIndex' | 'variant'> & {
|
||||
height: number;
|
||||
colors: ThemePalette;
|
||||
}) {
|
||||
const { width } = useWindowDimensions();
|
||||
const showTime = monthChipShowsTime(showTimeInMonthView, width / 7);
|
||||
return (
|
||||
<View style={[styles.fullWeekRow, { height }]}>
|
||||
{showWeekNumbers && (
|
||||
<Text style={[styles.weekNumberCell, styles.fullWeekNumber]}>{weekNumberFor(days[0], weekStartsOn)}</Text>
|
||||
)}
|
||||
{days.map((d, i) => {
|
||||
const sameMonth = (activeMask & (1 << i)) !== 0;
|
||||
const today = i === todayIndex;
|
||||
const dayEvents = eventsOnDayFromIndex(index, d);
|
||||
const shown = fullCellChipCount(height, dayEvents.length);
|
||||
const overflow = dayEvents.length - shown;
|
||||
const label = labelMonths && d.getDate() === 1 ? format(d, 'MMM d', { locale }) : format(d, 'd');
|
||||
return (
|
||||
<Pressable
|
||||
key={i}
|
||||
style={({ pressed }) => [styles.fullCell, i === 0 && styles.fullCellFirst, pressed && styles.fullCellPressed]}
|
||||
onPress={() => onSelectDate(d)}
|
||||
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={format(d, 'PPPP', { locale })}
|
||||
>
|
||||
<View style={styles.fullNumberWrap}>
|
||||
<View style={[styles.fullNumber, today && styles.fullNumberToday]}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.fullNumberText,
|
||||
!sameMonth && styles.dayTextMuted,
|
||||
today && styles.fullNumberTextToday,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{dayEvents.slice(0, shown).map((event, idx) => {
|
||||
const inactive = isInactiveEvent(event, currentUserEmails);
|
||||
const colors = eventBlockColors(getEventColor(event, calendars), inactive, c);
|
||||
// No time on events that fill the whole day.
|
||||
const time = showTime && !event.showWithoutTime && !isTimedEventFullDayOnDate(event, d)
|
||||
? `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `
|
||||
: '';
|
||||
return (
|
||||
<View
|
||||
key={`${event.id}-${idx}`}
|
||||
style={[
|
||||
styles.fullChip,
|
||||
{ backgroundColor: colors.fill },
|
||||
colors.border !== null && [styles.chipInactive, { borderColor: colors.border }],
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.fullChipText, { color: colors.text }, inactive && styles.chipTextInactive]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="clip"
|
||||
>
|
||||
{time}{event.title || ''}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 && <Text style={styles.fullOverflow}>+{overflow}</Text>}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthViewInner({
|
||||
currentDate,
|
||||
selectedDate,
|
||||
@@ -252,12 +400,14 @@ function MonthViewInner({
|
||||
weekStartsOn = 0,
|
||||
showWeekNumbers = false,
|
||||
showTimeInMonthView = false,
|
||||
fill = false,
|
||||
timeFormat,
|
||||
currentUserEmails,
|
||||
onSelectDate,
|
||||
onLongPressDate,
|
||||
}: MonthViewProps) {
|
||||
const styles = useMonthStyles();
|
||||
const [gridHeight, setGridHeight] = React.useState(0);
|
||||
const { locale } = useCalendarLocale();
|
||||
const index = React.useMemo(
|
||||
() => eventsByDay ?? buildEventDayIndex(events),
|
||||
@@ -280,12 +430,25 @@ 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_FILL_MIN, Math.floor(gridHeight / rows.length))
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<View style={styles.grid}>
|
||||
<View style={fill ? styles.fullGrid : styles.grid}>
|
||||
<MonthWeekdayHeader weekStartsOn={weekStartsOn} showWeekNumbers={showWeekNumbers} styles={styles} />
|
||||
{rows.map((days) => (
|
||||
<View
|
||||
style={fill && styles.fullRows}
|
||||
onLayout={fill ? (e) => setGridHeight(e.nativeEvent.layout.height) : undefined}
|
||||
>
|
||||
{(!fill || fullRowHeight !== undefined) && rows.map((days) => (
|
||||
<MonthWeekRow
|
||||
key={days[0].toISOString()}
|
||||
variant={fill ? 'full' : 'compact'}
|
||||
height={fullRowHeight}
|
||||
days={days}
|
||||
activeMask={monthMask(days, activeMonth)}
|
||||
selectedIndex={dayIndexIn(days, selectedDate)}
|
||||
@@ -303,6 +466,7 @@ function MonthViewInner({
|
||||
onLongPressDate={onLongPressDate}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -386,5 +550,45 @@ function makeStyles(c: ThemePalette) {
|
||||
marginLeft: 1,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
fullGrid: { flex: 1 },
|
||||
fullRows: { flex: 1 },
|
||||
fullWeekRow: {
|
||||
flexDirection: 'row',
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
fullWeekNumber: { paddingTop: 6 },
|
||||
fullCell: {
|
||||
flex: 1,
|
||||
borderLeftWidth: StyleSheet.hairlineWidth,
|
||||
borderLeftColor: c.border,
|
||||
paddingHorizontal: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fullCellFirst: { borderLeftWidth: 0 },
|
||||
fullCellPressed: { backgroundColor: c.surfaceHover },
|
||||
fullNumberWrap: { height: FULL_NUMBER_HEIGHT, alignItems: 'center', justifyContent: 'center' },
|
||||
fullNumber: {
|
||||
minWidth: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
paddingHorizontal: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fullNumberToday: { backgroundColor: c.primary },
|
||||
fullNumberText: { fontSize: 12, lineHeight: 16, fontWeight: '500', color: c.text },
|
||||
fullNumberTextToday: { color: c.primaryForeground, fontWeight: '700' },
|
||||
fullChip: {
|
||||
height: FULL_CHIP_HEIGHT,
|
||||
marginBottom: FULL_CHIP_GAP,
|
||||
borderRadius: EVENT_RADIUS,
|
||||
paddingHorizontal: 3,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// 9px text on the phone's month bars (repos/branding/APP.md).
|
||||
fullChipText: { fontSize: 9, lineHeight: 12, fontWeight: '500' },
|
||||
fullOverflow: { fontSize: 10, lineHeight: 13, fontWeight: '500', color: c.textSecondary, paddingHorizontal: 3 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,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}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -52,6 +55,8 @@ interface TasksSheetProps {
|
||||
timeFormat?: TimeFormat;
|
||||
// Task to open in the editor when the sheet appears (from a calendar chip).
|
||||
initialTaskId?: string | null;
|
||||
// Open straight on an empty editor (the calendar's "new task" button).
|
||||
startNew?: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void> | void;
|
||||
onUpdate?: (id: string, changes: Partial<CalendarEvent>) => Promise<void> | void;
|
||||
@@ -94,6 +99,7 @@ export function TasksSheet({
|
||||
calendars,
|
||||
timeFormat,
|
||||
initialTaskId,
|
||||
startNew = false,
|
||||
onClose,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
@@ -104,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,
|
||||
@@ -132,10 +140,10 @@ export function TasksSheet({
|
||||
setExpanded(true);
|
||||
} else {
|
||||
setEditor(emptyTaskEditor(defaultCalendarId));
|
||||
setExpanded(false);
|
||||
setExpanded(startNew);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, initialTaskId]);
|
||||
}, [visible, initialTaskId, startNew]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible && !editor.calendarId && defaultCalendarId) {
|
||||
@@ -206,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 =>
|
||||
@@ -227,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}>
|
||||
@@ -244,7 +252,7 @@ export function TasksSheet({
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={styles.closeBtn}
|
||||
hitSlop={8}
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.close', 'Close')}
|
||||
>
|
||||
@@ -292,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
|
||||
@@ -522,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,
|
||||
@@ -544,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' },
|
||||
|
||||
@@ -37,6 +37,7 @@ import { isInactiveEvent } from '../../lib/calendar-participants';
|
||||
import { eventBlockColors } from '../../lib/event-colors';
|
||||
import {
|
||||
buildAllDaySegments,
|
||||
columnsPerScreen,
|
||||
headerColumnRange,
|
||||
hourAtOffset,
|
||||
timeGridFocusColumn,
|
||||
@@ -60,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. */
|
||||
@@ -95,6 +98,7 @@ interface TimeGridScrollViewProps {
|
||||
*/
|
||||
function TimeGridScrollViewInner({
|
||||
mode,
|
||||
multiDays = 3,
|
||||
focus,
|
||||
window,
|
||||
selectedDate,
|
||||
@@ -119,7 +123,7 @@ function TimeGridScrollViewInner({
|
||||
[eventsByDay, events],
|
||||
);
|
||||
|
||||
const perScreen = mode === 'week' ? 7 : 1;
|
||||
const perScreen = columnsPerScreen(mode, multiDays);
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const [rootWidth, setRootWidth] = React.useState(screenWidth);
|
||||
const listWidth = Math.max(perScreen, rootWidth - GUTTER_WIDTH);
|
||||
@@ -216,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)),
|
||||
@@ -238,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;
|
||||
@@ -288,6 +298,7 @@ function TimeGridScrollViewInner({
|
||||
const handleLongPressAt = React.useCallback(
|
||||
(day: Date, hour: number) => {
|
||||
if (!onCreateAtTime) return;
|
||||
setDraft(null);
|
||||
// A display date (that hour in the calendar's zone); the editor turns
|
||||
// it into the real instant when it saves (eventTimeFieldsToSave).
|
||||
const date = new Date(day);
|
||||
@@ -297,6 +308,14 @@ function TimeGridScrollViewInner({
|
||||
[onCreateAtTime],
|
||||
);
|
||||
|
||||
// A tap on an empty slot marks it with a "New event" block; tapping that
|
||||
// block creates the event. Two taps, so a stray tap never opens the editor.
|
||||
const [draft, setDraft] = React.useState<{ key: string; hour: number } | null>(null);
|
||||
const handleTapAt = React.useCallback((day: Date, hour: number) => {
|
||||
const key = dayKey(day);
|
||||
setDraft((cur) => (cur && cur.key === key && cur.hour === hour ? null : { key, hour }));
|
||||
}, []);
|
||||
|
||||
const todayKey = dayKey(displayNow());
|
||||
const renderItem = React.useCallback(
|
||||
({ item: day }: ListRenderItemInfo<Date>) => (
|
||||
@@ -312,11 +331,13 @@ function TimeGridScrollViewInner({
|
||||
styles={styles}
|
||||
onSelectEvent={onSelectEvent}
|
||||
onLongPressAt={onCreateAtTime ? handleLongPressAt : undefined}
|
||||
onTapAt={onCreateAtTime ? handleTapAt : undefined}
|
||||
draftHour={draft && draft.key === dayKey(day) ? draft.hour : -1}
|
||||
/>
|
||||
),
|
||||
[
|
||||
layoutsFor, colWidth, todayKey, nowMinutes, calendars, timeFormat, currentUserEmails, c, styles,
|
||||
onSelectEvent, onCreateAtTime, handleLongPressAt,
|
||||
onSelectEvent, onCreateAtTime, handleLongPressAt, handleTapAt, draft,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -367,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 (
|
||||
@@ -376,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>
|
||||
@@ -442,6 +480,7 @@ function TimeGridScrollViewInner({
|
||||
data={days}
|
||||
keyExtractor={dayKey}
|
||||
renderItem={renderItem}
|
||||
extraData={draft}
|
||||
getItemLayout={getItemLayout}
|
||||
initialScrollIndex={initialIndexRef.current.index}
|
||||
// Days added at the start keep the columns on screen in place.
|
||||
@@ -534,6 +573,8 @@ const DayColumn = React.memo(function DayColumn({
|
||||
styles,
|
||||
onSelectEvent,
|
||||
onLongPressAt,
|
||||
onTapAt,
|
||||
draftHour,
|
||||
}: {
|
||||
day: Date;
|
||||
layouts: TimedEventLayout[];
|
||||
@@ -547,6 +588,9 @@ const DayColumn = React.memo(function DayColumn({
|
||||
styles: GridStyles;
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
onLongPressAt?: (day: Date, hour: number) => void;
|
||||
onTapAt?: (day: Date, hour: number) => void;
|
||||
/** Hour of the "New event" block in this column, or -1. */
|
||||
draftHour: number;
|
||||
}) {
|
||||
const { t } = useCalendarLocale();
|
||||
const handleLongPress = React.useCallback(
|
||||
@@ -555,9 +599,16 @@ const DayColumn = React.memo(function DayColumn({
|
||||
},
|
||||
[onLongPressAt, day],
|
||||
);
|
||||
const handlePress = React.useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
onTapAt?.(day, hourAtOffset(e.nativeEvent.locationY, HOUR_HEIGHT));
|
||||
},
|
||||
[onTapAt, day],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.dayCol, { width }]}
|
||||
onPress={onTapAt ? handlePress : undefined}
|
||||
onLongPress={onLongPressAt ? handleLongPress : undefined}
|
||||
>
|
||||
{layouts.map(({ event, column, totalColumns, startMinutes, endMinutes, continuesBefore, continuesAfter }) => {
|
||||
@@ -584,6 +635,18 @@ const DayColumn = React.memo(function DayColumn({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{draftHour >= 0 && (
|
||||
<Pressable
|
||||
style={[styles.draft, { top: draftHour * HOUR_HEIGHT, height: HOUR_HEIGHT - 1 }]}
|
||||
onPress={() => onLongPressAt?.(day, draftHour)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('calendar.events.new_event', 'New event')}
|
||||
>
|
||||
<Text style={styles.draftText} numberOfLines={1}>
|
||||
{t('calendar.events.new_event', 'New event')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{nowMinutes >= 0 && (
|
||||
<View pointerEvents="none" style={[styles.nowLine, { top: (nowMinutes / 60) * HOUR_HEIGHT }]}>
|
||||
<View style={styles.nowDot} />
|
||||
@@ -683,5 +746,18 @@ function makeStyles(c: ThemePalette) {
|
||||
backgroundColor: c.error,
|
||||
},
|
||||
nowBar: { flex: 1, height: 1, backgroundColor: c.error },
|
||||
draft: {
|
||||
position: 'absolute',
|
||||
left: 1,
|
||||
right: 2,
|
||||
borderRadius: 2,
|
||||
borderWidth: 1,
|
||||
borderColor: c.primary,
|
||||
backgroundColor: c.primaryBg,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 2,
|
||||
zIndex: 2,
|
||||
},
|
||||
draftText: { fontSize: 12, lineHeight: 16, fontWeight: '500', color: c.text },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user