mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c9bbcc383 | ||
|
|
c7eda4735c | ||
|
|
5f5ffdaddb | ||
|
|
083ec958a0 | ||
|
|
2b486cb1ef | ||
|
|
86400b124a | ||
|
|
323797b4ee | ||
|
|
9166b0fb3c | ||
|
|
1f253e9488 | ||
|
|
d22fb8d5d0 | ||
|
|
e0f18713aa | ||
|
|
56d826c508 | ||
|
|
e5492b89c8 | ||
|
|
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 | ||
|
|
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';
|
||||
@@ -38,15 +39,18 @@ import {
|
||||
type NotificationTapPayload,
|
||||
} from './src/lib/push-notifications';
|
||||
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
|
||||
|
||||
// Pause between a mute change and the push-filter update it sends (#78).
|
||||
const MUTES_RESYNC_DELAY_MS = 1_000;
|
||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||
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 +61,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 +123,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 +247,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 +265,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 +313,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 +332,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 +401,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) {
|
||||
@@ -685,6 +687,41 @@ export default function App() {
|
||||
};
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
|
||||
|
||||
// Muting a folder or account changes the server-side push filter (#78),
|
||||
// which the setup above would only send at the next launch. Send it now,
|
||||
// after a short pause so a few taps in a row make one update. Compared by
|
||||
// content, since hydration hands back an equal but new object; the value
|
||||
// seen first after hydration is the baseline, not a change.
|
||||
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
|
||||
const settingsHydrated = useSettingsStore((s) => s.hydrated);
|
||||
const mutesKey = JSON.stringify(notificationMutes);
|
||||
const lastMutesKey = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (!settingsHydrated) return;
|
||||
if (lastMutesKey.current === null || lastMutesKey.current === mutesKey) {
|
||||
lastMutesKey.current = mutesKey;
|
||||
return;
|
||||
}
|
||||
lastMutesKey.current = mutesKey;
|
||||
if (!isAuthenticated || !client || !emailNotificationsEnabled) return;
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
if (!relayBaseUrl || cancelled) return;
|
||||
try {
|
||||
await resyncPushNotifications({ relayBaseUrl, accountLabel: client.username ?? undefined });
|
||||
} catch (error) {
|
||||
console.warn('[push] mute update failed:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
}, MUTES_RESYNC_DELAY_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// Only a change of the mutes should send an update; the rest is read live.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mutesKey, settingsHydrated]);
|
||||
|
||||
// Live updates (SSE with polling fallback), re-armed on every account
|
||||
// switch and every re-established session — the singleton `client` object
|
||||
// never changes identity, so it cannot be the dependency on its own.
|
||||
@@ -830,10 +867,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
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug builds load the bundle from Metro over http, which the main config
|
||||
forbids (a network security config replaces the manifest's
|
||||
usesCleartextTraffic). Same trust anchors as the release config.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -21,11 +21,11 @@
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules" android:networkSecurityConfig="@xml/network_security_config">
|
||||
<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
|
||||
|
||||
@@ -15,6 +15,7 @@ class BulwarkFcmPackage : ReactPackage {
|
||||
add(BulwarkClientCertModule(reactContext))
|
||||
add(BulwarkDeviceSyncModule(reactContext))
|
||||
add(BulwarkWidgetsModule(reactContext))
|
||||
add(BulwarkPdfModule(reactContext))
|
||||
// The bridge registers React Native's own module under this name.
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||
|
||||
+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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Renders PDF pages to PNG files for the in-app file preview, with the
|
||||
* platform's own PdfRenderer: Android has no PDF view, and its WebView
|
||||
* doesn't show PDFs either. The preview draws the pages in a WebView of
|
||||
* its own so they zoom, asking for each page as it scrolls into view.
|
||||
*
|
||||
* PdfRenderer is not thread-safe and allows one open page at a time, so
|
||||
* every call runs on one background thread, and the open document is kept
|
||||
* between calls for the file the preview shows.
|
||||
*/
|
||||
class BulwarkPdfModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "BulwarkPdf").apply { isDaemon = true } }
|
||||
private var openPath: String? = null
|
||||
private var descriptor: ParcelFileDescriptor? = null
|
||||
private var renderer: PdfRenderer? = null
|
||||
|
||||
override fun getName(): String = "BulwarkPdf"
|
||||
|
||||
override fun invalidate() {
|
||||
worker.execute { closeDocument() }
|
||||
worker.shutdown()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
/** Page count and each page's size in points: `{ pages: [{ width, height }] }`. */
|
||||
@ReactMethod
|
||||
fun open(fileUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
try {
|
||||
val doc = document(fileUri)
|
||||
val pages = Arguments.createArray()
|
||||
for (i in 0 until doc.pageCount) {
|
||||
doc.openPage(i).use { page ->
|
||||
pages.pushMap(Arguments.createMap().apply {
|
||||
putInt("width", page.width)
|
||||
putInt("height", page.height)
|
||||
})
|
||||
}
|
||||
}
|
||||
promise.resolve(Arguments.createMap().apply { putArray("pages", pages) })
|
||||
} catch (e: SecurityException) {
|
||||
// Password-protected; PdfRenderer can't open those.
|
||||
closeDocument()
|
||||
promise.reject("E_PDF_PROTECTED", e.message, e)
|
||||
} catch (e: Throwable) {
|
||||
closeDocument()
|
||||
promise.reject("E_PDF_OPEN", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Render one page `width` pixels wide into `outUri` (a PNG). Resolves with the file URI. */
|
||||
@ReactMethod
|
||||
fun renderPage(fileUri: String, index: Int, width: Int, outUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
try {
|
||||
val doc = document(fileUri)
|
||||
if (index < 0 || index >= doc.pageCount) throw IndexOutOfBoundsException("page $index")
|
||||
doc.openPage(index).use { page ->
|
||||
val w = width.coerceIn(1, MAX_PAGE_PX)
|
||||
val h = (w.toLong() * page.height / page.width.coerceAtLeast(1)).toInt().coerceIn(1, MAX_PAGE_PX * 2)
|
||||
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
try {
|
||||
// Pages are transparent where nothing is drawn.
|
||||
bitmap.eraseColor(Color.WHITE)
|
||||
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
val out = fileOf(outUri)
|
||||
// The preview's folder is gone once it closed; a page
|
||||
// still queued then must not bring it back.
|
||||
if (out.parentFile?.isDirectory != true) throw IllegalStateException("preview closed")
|
||||
FileOutputStream(out).use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) }
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
promise.resolve(outUri)
|
||||
} catch (e: Throwable) {
|
||||
promise.reject("E_PDF_RENDER", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the document the preview showed. */
|
||||
@ReactMethod
|
||||
fun close(fileUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
if (openPath == fileOf(fileUri).absolutePath) closeDocument()
|
||||
promise.resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun document(fileUri: String): PdfRenderer {
|
||||
val path = fileOf(fileUri).absolutePath
|
||||
renderer?.let { if (openPath == path) return it }
|
||||
closeDocument()
|
||||
val fd = ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
try {
|
||||
val doc = PdfRenderer(fd)
|
||||
descriptor = fd
|
||||
renderer = doc
|
||||
openPath = path
|
||||
return doc
|
||||
} catch (e: Throwable) {
|
||||
fd.close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeDocument() {
|
||||
try { renderer?.close() } catch (_: Throwable) {}
|
||||
try { descriptor?.close() } catch (_: Throwable) {}
|
||||
renderer = null
|
||||
descriptor = null
|
||||
openPath = null
|
||||
}
|
||||
|
||||
private fun fileOf(uri: String): File {
|
||||
val path = if (uri.startsWith("file:")) Uri.parse(uri).path else uri
|
||||
return File(path ?: throw IllegalArgumentException("not a file: $uri"))
|
||||
}
|
||||
|
||||
companion object {
|
||||
// A 4096 px page bitmap is 64 MB; keep well below.
|
||||
private const val MAX_PAGE_PX = 2480
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Trust anchors for every connection the app makes (fetch, the client
|
||||
certificate module, the sync adapters). Android 7+ trusts only the system
|
||||
CAs unless the app opts in; a self-hosted server behind a private CA
|
||||
installed under Settings > Security > Encryption & credentials would fail
|
||||
with "Network request failed" otherwise (#3). Cleartext stays off, as it is
|
||||
for an app targeting API 28+ without this file; the debug source set
|
||||
overrides this for Metro.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
+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
|
||||
|
||||
@@ -175,7 +175,7 @@ RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pin
|
||||
- What RN does: single tap follows the global `mailAttachmentAction`; `jszip` is already a dependency (`package.json:36`) but unused for mail.
|
||||
- Fix hint: long-press sheet with "Open / Save / Share"; a "Download all" entry building the zip with `jszip` into `Paths.cache` then sharing.
|
||||
|
||||
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f** — `P3` — `partial`
|
||||
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f; audio/video, HTML, Markdown, SVG, Android PDFs and ZIPs since 0.1.80-0.1.82** — `P3` — `partial`
|
||||
- What WEB does: `FilePreviewModal` for images/PDF (bytes handed to pdf.js, #871)/text/markdown/audio/video/eml, script-bearing types forced to download (`components/mail/mail-app.tsx:2887-2935`, `lib/file-preview.ts:29-92`).
|
||||
- What RN does: Android hands the file to an external viewer via `ACTION_VIEW`, iOS opens the share sheet (`src/lib/email-export.ts:64-79`, `115-141`); nothing is previewed inside the app.
|
||||
- Fix hint: for images/PDF/text open a modal `WebView` on the cached `file://` (or use `expo-file-system`'s `File.uri` with a QuickLook bridge on iOS); keep the external-viewer path as fallback.
|
||||
|
||||
@@ -110,7 +110,7 @@ Filters: RN carries a byte-for-byte port of WEB's Sieve parser/generator/tests a
|
||||
- What RN does: `getDocumentAsync({ multiple: false })` (`src/screens/FilesScreen.tsx:357-360`); uploading the same name twice creates two nodes with the same name.
|
||||
- Fix hint: `multiple: true` + sequential upload loop; reuse WEB's `getUniqueName` against `visibleFiles`.
|
||||
|
||||
- [x] **No in-app preview (image/text/markdown/PDF/audio/video/eml)** — `P2` — `missing` — done in 409cdb1 (images, text and source files, `.eml`, and PDFs on iOS open in the reader's preview; PDFs on Android, audio, video, HTML and Markdown still go to another app)
|
||||
- [x] **No in-app preview (image/text/markdown/PDF/audio/video/eml)** — `P2` — `missing` — done in 409cdb1 (images, text and source files, `.eml`, and PDFs on iOS open in the reader's preview); since 0.1.80/0.1.81 also audio, video, SVG/ICO, HTML, Markdown, PDFs on Android (native `PdfRenderer`) and ZIP listings, through the one preview the reader, list, Files and compose share (`src/components/email/use-file-preview.ts`)
|
||||
- What WEB does: `ImagePreviewModal` and `FilePreviewModal` (text, markdown, PDF incl. mobile pdf.js viewer, audio, video, EML) chosen via `getFilePreviewKind` (`lib/file-preview.ts:29-68`; `components/files/file-preview-modal.tsx`, `image-preview-modal.tsx`, `pdf-mobile-viewer.tsx`; `files-app.tsx:487-495`, `690-708`).
|
||||
- What RN does: tapping a file calls `shareAttachment` which downloads to cache and hands off to the OS share/viewer sheet (`src/screens/FilesScreen.tsx:248-258`; `src/lib/email-export.ts` "preview" variant). Works but there is no inline viewer and no "recent files" tracking.
|
||||
- Fix hint: reuse whatever attachment viewer the mail area has (image modal / WebView for text+PDF); N/A if the team decides the OS viewer is the mobile answer - then close this item.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+953
-92
File diff suppressed because it is too large
Load Diff
+951
-90
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4507
-3646
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+20
-7
@@ -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,14 @@
|
||||
"preview": {
|
||||
"open_external": "فتح في تطبيق",
|
||||
"pdf_external": "تُفتح ملفات PDF في عارض PDF على هذا الجهاز.",
|
||||
"unsupported": "لا توجد معاينة لهذا النوع من الملفات."
|
||||
"unsupported": "لا توجد معاينة لهذا النوع من الملفات.",
|
||||
"media_unsupported": "لا يمكن تشغيل هذا الملف هنا.",
|
||||
"pdf_protected": "ملف PDF هذا محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.",
|
||||
"pdf_unreadable": "لا يمكن عرض ملف PDF هذا هنا.",
|
||||
"archive_files": "{count, plural, zero {لا ملفات} one {ملف واحد} two {ملفان} few {# ملفات} many {# ملفًا} other {# ملف}}",
|
||||
"archive_first_files": "أول {count} ملف",
|
||||
"archive_protected": "هذا الأرشيف محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.",
|
||||
"archive_unreadable": "لا يمكن فتح هذا الأرشيف هنا."
|
||||
},
|
||||
"received_via": "إلى {name}",
|
||||
"remove_color": "إزالة الوسم",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "تعذّر فتح الملف",
|
||||
"open_with": "فتح باستخدام…",
|
||||
"preview_too_large": "هذا الملف كبير جدًا بحيث لا يمكن معاينته.",
|
||||
"selected_count": "{count, plural, zero {تم تحديد #} one {تم تحديد عنصر واحد} two {تم تحديد عنصرين} few {تم تحديد # عناصر} many {تم تحديد # عنصرًا} other {تم تحديد # عنصر}}",
|
||||
"share_add_people": "إضافة أشخاص",
|
||||
"share_empty": "لم تتم المشاركة مع أي شخص بعد",
|
||||
"share_error": "فشل تحديث المشاركة",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "الإضافات",
|
||||
"themes": "السمات",
|
||||
"updates": "التحديثات"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "لوحات الألوان الفاتحة والداكنة الافتراضية.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "اختر سمة ألوان. تتبع السمات إعداد الوضع الفاتح/الداكن من المظهر؛ وتُدار حزم السمات المخصصة في بريد الويب.",
|
||||
"title": "السمات"
|
||||
"mobile_description": "اختر سمة ألوان. تتبع السمات إعداد الوضع الفاتح/الداكن من المظهر؛ وتُدار حزم السمات المخصصة في بريد الويب."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,7 @@
|
||||
"release_page_pending_desc": "تابع تقدّم البناء أو نزّل ملف APK يدويًا.",
|
||||
"severity_deprecated": "إلزامي",
|
||||
"severity_security": "أمني",
|
||||
"title": "تحديثات التطبيق"
|
||||
"title": "تحديثات التطبيق",
|
||||
"verifying": "جارٍ التحقق…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"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í.",
|
||||
"pdf_protected": "Aquest PDF està protegit amb contrasenya. Obre'l en una altra aplicació per introduir-la.",
|
||||
"pdf_unreadable": "Aquest PDF no es pot mostrar aquí.",
|
||||
"archive_files": "{count, plural, one {# fitxer} other {# fitxers}}",
|
||||
"archive_first_files": "Els primers {count} fitxers",
|
||||
"archive_protected": "Aquest arxiu està protegit amb contrasenya. Obre'l en una altra aplicació per introduir-la.",
|
||||
"archive_unreadable": "Aquest arxiu no es pot obrir aquí."
|
||||
},
|
||||
"received_via": "per a {name}",
|
||||
"remove_color": "Elimina l'etiqueta",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "No s'ha pogut obrir el fitxer",
|
||||
"open_with": "Obre amb…",
|
||||
"preview_too_large": "Aquest fitxer és massa gran per previsualitzar-lo.",
|
||||
"selected_count": "{count, plural, one {# seleccionat} other {# seleccionats}}",
|
||||
"share_add_people": "Afegeix persones",
|
||||
"share_empty": "Encara no s'ha compartit amb ningú",
|
||||
"share_error": "No s'ha pogut actualitzar la compartició",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Connectors",
|
||||
"themes": "Temes",
|
||||
"updates": "Actualitzacions"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Les paletes clara i fosca per defecte.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Trieu un tema de color. Els temes segueixen el paràmetre clar/fosc d'Aparença; els paquets de temes personalitzats es gestionen al webmail.",
|
||||
"title": "Temes"
|
||||
"mobile_description": "Trieu un tema de color. Els temes segueixen el paràmetre clar/fosc d'Aparença; els paquets de temes personalitzats es gestionen al webmail."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,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…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"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.",
|
||||
"pdf_protected": "Toto PDF je chráněné heslem. Otevřete ho v jiné aplikaci a zadejte heslo.",
|
||||
"pdf_unreadable": "Toto PDF zde nelze zobrazit.",
|
||||
"archive_files": "{count, plural, one {# soubor} few {# soubory} many {# souboru} other {# souborů}}",
|
||||
"archive_first_files": "Prvních {count} souborů",
|
||||
"archive_protected": "Tento archiv je chráněný heslem. Otevřete ho v jiné aplikaci a zadejte heslo.",
|
||||
"archive_unreadable": "Tento archiv zde nelze otevřít."
|
||||
},
|
||||
"received_via": "pro {name}",
|
||||
"remove_color": "Odebrat štítek",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Soubor se nepodařilo otevřít",
|
||||
"open_with": "Otevřít pomocí…",
|
||||
"preview_too_large": "Tento soubor je příliš velký pro náhled.",
|
||||
"selected_count": "{count, plural, one {# vybraná} few {# vybrané} many {# vybrané} other {# vybraných}}",
|
||||
"share_add_people": "Přidat osoby",
|
||||
"share_empty": "Zatím nikomu nesdíleno",
|
||||
"share_error": "Aktualizace sdílení selhala",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Pluginy",
|
||||
"themes": "Motivy",
|
||||
"updates": "Aktualizace"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Výchozí světlé a tmavé palety.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Vyberte barevný motiv. Motivy se řídí nastavením světlý/tmavý ve Vzhledu; vlastní balíčky motivů se spravují ve webmailu.",
|
||||
"title": "Motivy"
|
||||
"mobile_description": "Vyberte barevný motiv. Motivy se řídí nastavením světlý/tmavý ve Vzhledu; vlastní balíčky motivů se spravují ve webmailu."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,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í…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"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.",
|
||||
"pdf_protected": "Denne PDF er beskyttet med adgangskode. Åbn den i en anden app for at indtaste adgangskoden.",
|
||||
"pdf_unreadable": "Denne PDF kan ikke vises her.",
|
||||
"archive_files": "{count, plural, one {# fil} other {# filer}}",
|
||||
"archive_first_files": "De første {count} filer",
|
||||
"archive_protected": "Dette arkiv er beskyttet med adgangskode. Åbn det i en anden app for at indtaste adgangskoden.",
|
||||
"archive_unreadable": "Dette arkiv kan ikke åbnes her."
|
||||
},
|
||||
"received_via": "til {name}",
|
||||
"remove_color": "Fjern tag",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Kunne ikke åbne filen",
|
||||
"open_with": "Åbn med…",
|
||||
"preview_too_large": "Denne fil er for stor til forhåndsvisning.",
|
||||
"selected_count": "{count, plural, one {# valgt} other {# valgte}}",
|
||||
"share_add_people": "Tilføj personer",
|
||||
"share_empty": "Ikke delt med nogen endnu",
|
||||
"share_error": "Kunne ikke opdatere deling",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Plugins",
|
||||
"themes": "Temaer",
|
||||
"updates": "Opdateringer"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Standardpaletterne til lys og mørk.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Vælg et farvetema. Temaer følger indstillingen for lys/mørk under Udseende; brugerdefinerede temapakker administreres i webmailen.",
|
||||
"title": "Temaer"
|
||||
"mobile_description": "Vælg et farvetema. Temaer følger indstillingen for lys/mørk under Udseende; brugerdefinerede temapakker administreres i webmailen."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,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…"
|
||||
}
|
||||
}
|
||||
|
||||
+130
-11
@@ -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,14 @@
|
||||
"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.",
|
||||
"pdf_protected": "Diese PDF ist passwortgeschützt. Öffne sie in einer anderen App, um das Passwort einzugeben.",
|
||||
"pdf_unreadable": "Diese PDF kann hier nicht angezeigt werden.",
|
||||
"archive_files": "{count, plural, one {# Datei} other {# Dateien}}",
|
||||
"archive_first_files": "Die ersten {count} Dateien",
|
||||
"archive_protected": "Dieses Archiv ist passwortgeschützt. Öffne es in einer anderen App, um das Passwort einzugeben.",
|
||||
"archive_unreadable": "Dieses Archiv kann hier nicht geöffnet werden."
|
||||
},
|
||||
"received_via": "an {name}",
|
||||
"remove_color": "Label entfernen",
|
||||
@@ -429,7 +451,6 @@
|
||||
"open_error": "Die Datei konnte nicht geöffnet werden",
|
||||
"open_with": "Öffnen mit…",
|
||||
"preview_too_large": "Diese Datei ist zu groß für eine Vorschau.",
|
||||
"selected_count": "{count, plural, one {# ausgewählt} other {# ausgewählt}}",
|
||||
"share_add_people": "Personen hinzufügen",
|
||||
"share_empty": "Noch mit niemandem geteilt",
|
||||
"share_error": "Freigabe konnte nicht aktualisiert werden",
|
||||
@@ -438,6 +459,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 +654,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 +881,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 +1004,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.",
|
||||
@@ -1086,8 +1119,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?",
|
||||
@@ -1103,14 +1138,92 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Die Standardpaletten für Hell und Dunkel.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Wählen Sie ein Farbdesign. Designs folgen der Hell/Dunkel-Einstellung unter Darstellung; eigene Designpakete werden im Webmail verwaltet.",
|
||||
"title": "Designs"
|
||||
"mobile_description": "Wählen Sie ein Farbdesign. Designs folgen der Hell/Dunkel-Einstellung unter Darstellung; eigene Designpakete werden im Webmail verwaltet."
|
||||
},
|
||||
"vacation": {
|
||||
"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 +1232,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 +1286,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": {
|
||||
|
||||
+160
-15
@@ -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": {
|
||||
@@ -270,7 +284,6 @@
|
||||
"attach_camera": "Camera",
|
||||
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||
"attachment_reminder_title": "Forgot an attachment?",
|
||||
"attachment_too_large": "This file is larger than the server allows ({size} > {max}).",
|
||||
"attachments_too_large_total": "The attachments would exceed the {max} this server allows per message.",
|
||||
"editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
|
||||
"format_plain_text": "Switch to plain text",
|
||||
@@ -283,6 +296,7 @@
|
||||
"no_sent_folder": "No Sent folder found",
|
||||
"permission_camera": "Camera permission is required to take a photo.",
|
||||
"permission_photos": "Photo library permission is required to attach images.",
|
||||
"read_receipt_off": "Request a read receipt",
|
||||
"recipient_add_contact": "Add to contacts",
|
||||
"recipient_copy": "Copy address",
|
||||
"recipient_move_to": "Move to {field}",
|
||||
@@ -308,13 +322,14 @@
|
||||
"send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.",
|
||||
"send_timeout_title": "No answer from the server",
|
||||
"toolbar": {
|
||||
"background_color": "Background color",
|
||||
"remove_background_color": "Remove background color"
|
||||
"insert_image": "Insert image",
|
||||
"remove_link": "Remove link"
|
||||
},
|
||||
"undo_send_label": "Sending in {seconds}s…",
|
||||
"upload_failed_short": "Upload failed",
|
||||
"uploading": "Uploading...",
|
||||
"uploading_pct": "Uploading {pct}%",
|
||||
"use_template": "Template",
|
||||
"validation": {
|
||||
"subject_required": "Add a subject"
|
||||
}
|
||||
@@ -406,8 +421,15 @@
|
||||
"forwarded": "Forwarded",
|
||||
"load_failed": "Failed to load email",
|
||||
"preview": {
|
||||
"archive_files": "{count, plural, one {# file} other {# files}}",
|
||||
"archive_first_files": "The first {count} files",
|
||||
"archive_protected": "This archive is password-protected. Open it in another app to enter the password.",
|
||||
"archive_unreadable": "This archive can't be opened here.",
|
||||
"media_unsupported": "This file can't be played here.",
|
||||
"open_external": "Open in app",
|
||||
"pdf_external": "PDFs open in your PDF viewer on this device.",
|
||||
"pdf_protected": "This PDF is password-protected. Open it in another app to enter the password.",
|
||||
"pdf_unreadable": "This PDF can't be shown here.",
|
||||
"unsupported": "No preview for this file type."
|
||||
},
|
||||
"received_via": "to {name}",
|
||||
@@ -439,7 +461,23 @@
|
||||
"open_error": "Could not open the file",
|
||||
"open_with": "Open with…",
|
||||
"preview_too_large": "This file is too large to preview.",
|
||||
"selected_count": "{count, plural, one {# selected} other {# selected}}",
|
||||
"settings_behavior": "Behavior",
|
||||
"settings_colored_icons": "Colored Icons",
|
||||
"settings_colored_icons_desc": "Use colorful icons instead of monochrome",
|
||||
"settings_default_sort": "Default Sort",
|
||||
"settings_default_sort_desc": "Choose the default sorting for files",
|
||||
"settings_default_view": "Default View",
|
||||
"settings_default_view_desc": "Choose between grid and list layout",
|
||||
"settings_display": "Display",
|
||||
"settings_icons": "Icons",
|
||||
"settings_show_hidden": "Show Hidden Files",
|
||||
"settings_show_hidden_desc": "Display files and folders that start with a dot",
|
||||
"settings_show_icons": "Show File Icons",
|
||||
"settings_show_icons_desc": "Display icons next to files and folders",
|
||||
"settings_show_thumbnails": "Show Thumbnails",
|
||||
"settings_show_thumbnails_desc": "Display image previews instead of icons for image files",
|
||||
"settings_sort_direction": "Sort Direction",
|
||||
"settings_sort_direction_desc": "Choose ascending or descending order",
|
||||
"share_add_people": "Add people",
|
||||
"share_empty": "Not shared with anyone yet",
|
||||
"share_error": "Failed to update sharing",
|
||||
@@ -448,11 +486,13 @@
|
||||
"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",
|
||||
"shared_with": "Shared with",
|
||||
"sharing_access": "Sharing & access",
|
||||
"upload": "Upload",
|
||||
"upload_unavailable": "The file picker is not installed in this build. Rebuild the app to enable uploads.",
|
||||
"uploading_progress": "Uploading {current}/{total} · {name}"
|
||||
},
|
||||
@@ -566,6 +606,10 @@
|
||||
"qr_title": "Sign-in code",
|
||||
"scan_desc": "The fastest way in if you're signed in on the web",
|
||||
"scan_title": "Scan a sign-in code",
|
||||
"server_cert_active": "Signing in with the client certificate \"{alias}\". Tap to choose another.",
|
||||
"server_cert_clear": "Sign in without the certificate",
|
||||
"server_cert_failed": "Could not pick a certificate",
|
||||
"server_cert_hint": "Does your server ask for a client certificate? Pick one installed on this phone.",
|
||||
"server_hint": "Same address you type in the browser. We'll add https:// for you.",
|
||||
"server_label": "Server address",
|
||||
"server_not_found": "We couldn't find a server for {domain}. If you self-host, the address is the one you use for webmail.",
|
||||
@@ -610,6 +654,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 +698,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 +707,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": {
|
||||
@@ -791,12 +863,6 @@
|
||||
"plain_text_mode": {
|
||||
"description_mobile": "Compose and read in plain text only."
|
||||
},
|
||||
"reply_identity_match": {
|
||||
"description": "Which received addresses count as yours. Exact address only picks one of your configured identities. Same domain also treats any other address on one of your identity domains as a catch-all alias and rewrites the From header to it. Choose exact address if those addresses are distribution lists rather than aliases.",
|
||||
"domain": "Any address on my domains",
|
||||
"exact": "Exact address only",
|
||||
"label": "Received Address Matching"
|
||||
},
|
||||
"send_delay": {
|
||||
"description_mobile": "Hold outgoing mail for a few seconds so you can cancel it. Requires server support."
|
||||
},
|
||||
@@ -873,6 +939,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 +977,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",
|
||||
@@ -927,6 +1000,8 @@
|
||||
"email": {
|
||||
"app_icon_badge": "Unread count on app icon",
|
||||
"app_icon_badge_desc": "Show the inbox unread count as a badge on the app icon.",
|
||||
"mute_account": "Mute this account",
|
||||
"mute_account_desc": "No alerts for new mail in this account. Long-press a folder in the sidebar to mute or unmute it on its own.",
|
||||
"sound_channel_hint": "Sound and vibration for mail alerts are controlled by the Android notification channel: long-press a notification or open the system app settings."
|
||||
},
|
||||
"push": {
|
||||
@@ -975,23 +1050,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 +1084,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,9 +1199,37 @@
|
||||
"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": {
|
||||
"composing": "Signature, send delay, drafts",
|
||||
"contacts": "Address books, sorting",
|
||||
"content_senders": "Remote images, trusted senders",
|
||||
"downloads": "File names for saved mail",
|
||||
"events_off": "events off",
|
||||
"events_on": "events on",
|
||||
"files": "View, sorting, thumbnails",
|
||||
"filters": "Rules for incoming mail",
|
||||
"folders": "Create, rename, roles",
|
||||
"identities": "Addresses you send from",
|
||||
"keywords": "Tag names and colours",
|
||||
"layout": "Quick actions, swipes, list order",
|
||||
"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",
|
||||
"reading": "Read status, delete, attachments",
|
||||
"security": "Password, two-factor, app passwords",
|
||||
"sidebar_apps": "Web apps in the side menu",
|
||||
"templates": "Reusable messages",
|
||||
"updates": "Install new versions",
|
||||
"vacation": "Automatic reply while away",
|
||||
"version": "Version {version}",
|
||||
"week_starts": "week starts {day}"
|
||||
},
|
||||
"tabs": {
|
||||
"about": "About",
|
||||
"plugins": "Plugins",
|
||||
"themes": "Themes",
|
||||
"storage": "Storage & backup",
|
||||
"troubleshooting": "Troubleshooting",
|
||||
"updates": "Updates"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1119,11 +1246,23 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "The default light and dark palettes.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail.",
|
||||
"title": "Themes"
|
||||
"mobile_description": "Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail."
|
||||
},
|
||||
"troubleshooting": {
|
||||
"cache_desc": "When a list looks stale or wrong.",
|
||||
"cache_title": "Cached data",
|
||||
"row_desc": "Refresh cached data, debug logging",
|
||||
"title": "Troubleshooting"
|
||||
},
|
||||
"vacation": {
|
||||
"date_range": {
|
||||
"description": "Optionally limit the auto-reply to a specific period",
|
||||
"title": "Date Range"
|
||||
},
|
||||
"message": {
|
||||
"description": "The message that will be sent as a reply",
|
||||
"title": "Auto-Reply Message"
|
||||
},
|
||||
"warnings": {
|
||||
"date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\""
|
||||
}
|
||||
@@ -1135,6 +1274,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 +1328,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": {
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"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í.",
|
||||
"pdf_protected": "Este PDF está protegido con contraseña. Ábrelo en otra aplicación para introducirla.",
|
||||
"pdf_unreadable": "Este PDF no se puede mostrar aquí.",
|
||||
"archive_files": "{count, plural, one {# archivo} other {# archivos}}",
|
||||
"archive_first_files": "Los primeros {count} archivos",
|
||||
"archive_protected": "Este archivo comprimido está protegido con contraseña. Ábrelo en otra aplicación para introducirla.",
|
||||
"archive_unreadable": "Este archivo comprimido no se puede abrir aquí."
|
||||
},
|
||||
"received_via": "para {name}",
|
||||
"remove_color": "Eliminar etiqueta",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "No se pudo abrir el archivo",
|
||||
"open_with": "Abrir con…",
|
||||
"preview_too_large": "Este archivo es demasiado grande para la vista previa.",
|
||||
"selected_count": "{count, plural, one {# seleccionado} other {# seleccionados}}",
|
||||
"share_add_people": "Añadir personas",
|
||||
"share_empty": "Aún no se ha compartido con nadie",
|
||||
"share_error": "No se pudo actualizar el uso compartido",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Plugins",
|
||||
"themes": "Temas",
|
||||
"updates": "Actualizaciones"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Las paletas clara y oscura predeterminadas.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Elige un tema de color. Los temas siguen el ajuste claro/oscuro de Apariencia; los paquetes de temas personalizados se gestionan en el webmail.",
|
||||
"title": "Temas"
|
||||
"mobile_description": "Elige un tema de color. Los temas siguen el ajuste claro/oscuro de Apariencia; los paquetes de temas personalizados se gestionan en el webmail."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,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…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"preview": {
|
||||
"open_external": "باز کردن در برنامه",
|
||||
"pdf_external": "فایلهای PDF در نمایشگر PDF این دستگاه باز میشوند.",
|
||||
"unsupported": "برای این نوع فایل پیشنمایشی وجود ندارد."
|
||||
"unsupported": "برای این نوع فایل پیشنمایشی وجود ندارد.",
|
||||
"media_unsupported": "این فایل اینجا پخش نمیشود.",
|
||||
"pdf_protected": "این PDF با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.",
|
||||
"pdf_unreadable": "این PDF اینجا نمایش داده نمیشود.",
|
||||
"archive_files": "{count, plural, one {# فایل} other {# فایل}}",
|
||||
"archive_first_files": "{count} فایل نخست",
|
||||
"archive_protected": "این بایگانی با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.",
|
||||
"archive_unreadable": "این بایگانی اینجا باز نمیشود."
|
||||
},
|
||||
"received_via": "به {name}",
|
||||
"remove_color": "حذف برچسب",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "باز کردن فایل ممکن نشد",
|
||||
"open_with": "باز کردن با…",
|
||||
"preview_too_large": "این فایل برای پیشنمایش بیش از حد بزرگ است.",
|
||||
"selected_count": "{count, plural, one {# انتخاب شده} other {# انتخاب شده}}",
|
||||
"share_add_people": "افزودن افراد",
|
||||
"share_empty": "هنوز با کسی به اشتراک گذاشته نشده",
|
||||
"share_error": "بهروزرسانی اشتراکگذاری ناموفق بود",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "افزونهها",
|
||||
"themes": "تمها",
|
||||
"updates": "بهروزرسانیها"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "پالتهای روشن و تاریک پیشفرض.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "یک تم رنگی انتخاب کنید. تمها از تنظیم روشن/تاریک بخش ظاهر پیروی میکنند؛ بستههای تم سفارشی در ایمیل تحت وب مدیریت میشوند.",
|
||||
"title": "تمها"
|
||||
"mobile_description": "یک تم رنگی انتخاب کنید. تمها از تنظیم روشن/تاریک بخش ظاهر پیروی میکنند؛ بستههای تم سفارشی در ایمیل تحت وب مدیریت میشوند."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,7 @@
|
||||
"release_page_pending_desc": "روند ساخت را دنبال کنید یا APK را دستی دانلود کنید.",
|
||||
"severity_deprecated": "الزامی",
|
||||
"severity_security": "امنیتی",
|
||||
"title": "بهروزرسانیهای برنامه"
|
||||
"title": "بهروزرسانیهای برنامه",
|
||||
"verifying": "در حال بررسی…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"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.",
|
||||
"pdf_protected": "Ce PDF est protégé par un mot de passe. Ouvrez-le dans une autre application pour le saisir.",
|
||||
"pdf_unreadable": "Ce PDF ne peut pas être affiché ici.",
|
||||
"archive_files": "{count, plural, one {# fichier} other {# fichiers}}",
|
||||
"archive_first_files": "Les {count} premiers fichiers",
|
||||
"archive_protected": "Cette archive est protégée par un mot de passe. Ouvrez-la dans une autre application pour le saisir.",
|
||||
"archive_unreadable": "Cette archive ne peut pas être ouverte ici."
|
||||
},
|
||||
"received_via": "à {name}",
|
||||
"remove_color": "Retirer l'étiquette",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Impossible d'ouvrir le fichier",
|
||||
"open_with": "Ouvrir avec…",
|
||||
"preview_too_large": "Ce fichier est trop volumineux pour être prévisualisé.",
|
||||
"selected_count": "{count, plural, one {# sélectionné} other {# sélectionnés}}",
|
||||
"share_add_people": "Ajouter des personnes",
|
||||
"share_empty": "Pas encore partagé",
|
||||
"share_error": "Échec de la mise à jour du partage",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Plugins",
|
||||
"themes": "Thèmes",
|
||||
"updates": "Mises à jour"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Les palettes claire et sombre par défaut.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Choisissez un thème de couleurs. Les thèmes suivent le réglage clair/sombre de l'Apparence. Les packs de thèmes personnalisés se gèrent dans le webmail.",
|
||||
"title": "Thèmes"
|
||||
"mobile_description": "Choisissez un thème de couleurs. Les thèmes suivent le réglage clair/sombre de l'Apparence. Les packs de thèmes personnalisés se gèrent dans le webmail."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,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…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"preview": {
|
||||
"open_external": "פתח באפליקציה",
|
||||
"pdf_external": "קובצי PDF נפתחים במציג ה-PDF במכשיר זה.",
|
||||
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה."
|
||||
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה.",
|
||||
"media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן.",
|
||||
"pdf_protected": "קובץ ה-PDF הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.",
|
||||
"pdf_unreadable": "לא ניתן להציג את קובץ ה-PDF הזה כאן.",
|
||||
"archive_files": "{count, plural, one {קובץ אחד} two {שני קבצים} other {# קבצים}}",
|
||||
"archive_first_files": "{count} הקבצים הראשונים",
|
||||
"archive_protected": "הארכיון הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.",
|
||||
"archive_unreadable": "לא ניתן לפתוח את הארכיון הזה כאן."
|
||||
},
|
||||
"received_via": "אל {name}",
|
||||
"remove_color": "הסר תג",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "לא ניתן היה לפתוח את הקובץ",
|
||||
"open_with": "פתח באמצעות…",
|
||||
"preview_too_large": "הקובץ גדול מכדי להציג תצוגה מקדימה.",
|
||||
"selected_count": "{count, plural, one {נבחר פריט אחד} two {נבחרו שני פריטים} other {נבחרו # פריטים}}",
|
||||
"share_add_people": "הוסף אנשים",
|
||||
"share_empty": "עדיין לא משותף עם אף אחד",
|
||||
"share_error": "עדכון השיתוף נכשל",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "תוספים",
|
||||
"themes": "ערכות נושא",
|
||||
"updates": "עדכונים"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "ערכות הצבעים הבהירה והכהה המוגדרות כברירת מחדל.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "בחר ערכת צבעים. ערכות הנושא פועלות לפי הגדרת בהיר/כהה במראה; חבילות ערכות נושא מותאמות מנוהלות בדואר האינטרנט.",
|
||||
"title": "ערכות נושא"
|
||||
"mobile_description": "בחר ערכת צבעים. ערכות הנושא פועלות לפי הגדרת בהיר/כהה במראה; חבילות ערכות נושא מותאמות מנוהלות בדואר האינטרנט."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,7 @@
|
||||
"release_page_pending_desc": "עקוב אחר התקדמות הבנייה או הורד את ה-APK ידנית.",
|
||||
"severity_deprecated": "חובה",
|
||||
"severity_security": "אבטחה",
|
||||
"title": "עדכוני אפליקציה"
|
||||
"title": "עדכוני אפליקציה",
|
||||
"verifying": "מאמת…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"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.",
|
||||
"pdf_protected": "Ez a PDF jelszóval védett. Nyisd meg egy másik alkalmazásban a jelszó megadásához.",
|
||||
"pdf_unreadable": "Ez a PDF itt nem jeleníthető meg.",
|
||||
"archive_files": "{count, plural, one {# fájl} other {# fájl}}",
|
||||
"archive_first_files": "Az első {count} fájl",
|
||||
"archive_protected": "Ez az archívum jelszóval védett. Nyisd meg egy másik alkalmazásban a jelszó megadásához.",
|
||||
"archive_unreadable": "Ez az archívum itt nem nyitható meg."
|
||||
},
|
||||
"received_via": "címzett: {name}",
|
||||
"remove_color": "Címke eltávolítása",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Nem sikerült megnyitni a fájlt",
|
||||
"open_with": "Megnyitás ezzel…",
|
||||
"preview_too_large": "Ez a fájl túl nagy az előnézethez.",
|
||||
"selected_count": "{count, plural, one {# kijelölve} other {# kijelölve}}",
|
||||
"share_add_people": "Személyek hozzáadása",
|
||||
"share_empty": "Még nincs megosztva senkivel",
|
||||
"share_error": "Nem sikerült frissíteni a megosztást",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Bővítmények",
|
||||
"themes": "Témák",
|
||||
"updates": "Frissítések"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Az alapértelmezett világos és sötét paletták.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Válassz színtémát. A témák a Megjelenés világos/sötét beállítását követik; az egyéni témacsomagokat a webmailben kezelheted.",
|
||||
"title": "Témák"
|
||||
"mobile_description": "Válassz színtémát. A témák a Megjelenés világos/sötét beállítását követik; az egyéni témacsomagokat a webmailben kezelheted."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,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…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"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.",
|
||||
"pdf_protected": "Questo PDF è protetto da password. Aprilo in un'altra app per inserirla.",
|
||||
"pdf_unreadable": "Questo PDF non può essere visualizzato qui.",
|
||||
"archive_files": "{count, plural, one {# file} other {# file}}",
|
||||
"archive_first_files": "I primi {count} file",
|
||||
"archive_protected": "Questo archivio è protetto da password. Aprilo in un'altra app per inserirla.",
|
||||
"archive_unreadable": "Questo archivio non può essere aperto qui."
|
||||
},
|
||||
"received_via": "a {name}",
|
||||
"remove_color": "Rimuovi etichetta",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Impossibile aprire il file",
|
||||
"open_with": "Apri con…",
|
||||
"preview_too_large": "Questo file è troppo grande per l'anteprima.",
|
||||
"selected_count": "{count, plural, one {# selezionato} other {# selezionati}}",
|
||||
"share_add_people": "Aggiungi persone",
|
||||
"share_empty": "Non ancora condiviso con nessuno",
|
||||
"share_error": "Impossibile aggiornare la condivisione",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Plugin",
|
||||
"themes": "Temi",
|
||||
"updates": "Aggiornamenti"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Le palette chiara e scura predefinite.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Scegli un tema di colori. I temi seguono l'impostazione chiaro/scuro di Aspetto; i pacchetti di temi personalizzati si gestiscono nella webmail.",
|
||||
"title": "Temi"
|
||||
"mobile_description": "Scegli un tema di colori. I temi seguono l'impostazione chiaro/scuro di Aspetto; i pacchetti di temi personalizzati si gestiscono nella webmail."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,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…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"preview": {
|
||||
"open_external": "アプリで開く",
|
||||
"pdf_external": "PDF はこのデバイスの PDF ビューアーで開きます。",
|
||||
"unsupported": "このファイル形式はプレビューできません。"
|
||||
"unsupported": "このファイル形式はプレビューできません。",
|
||||
"media_unsupported": "このファイルはここでは再生できません。",
|
||||
"pdf_protected": "このPDFはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。",
|
||||
"pdf_unreadable": "このPDFはここでは表示できません。",
|
||||
"archive_files": "{count, plural, other {# 個のファイル}}",
|
||||
"archive_first_files": "最初の {count} 個のファイル",
|
||||
"archive_protected": "このアーカイブはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。",
|
||||
"archive_unreadable": "このアーカイブはここでは開けません。"
|
||||
},
|
||||
"received_via": "{name}宛",
|
||||
"remove_color": "ラベルを削除",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "ファイルを開けませんでした",
|
||||
"open_with": "別のアプリで開く…",
|
||||
"preview_too_large": "このファイルは大きすぎるため、プレビューできません。",
|
||||
"selected_count": "{count, plural, other {#件選択中}}",
|
||||
"share_add_people": "ユーザーを追加",
|
||||
"share_empty": "まだ誰にも共有されていません",
|
||||
"share_error": "共有の更新に失敗しました",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "プラグイン",
|
||||
"themes": "テーマ",
|
||||
"updates": "アップデート"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "デフォルトのライト/ダークパレットです。",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "カラーテーマを選択します。テーマは「外観」のライト/ダーク設定に従います。カスタムテーマパッケージはウェブメールで管理します。",
|
||||
"title": "テーマ"
|
||||
"mobile_description": "カラーテーマを選択します。テーマは「外観」のライト/ダーク設定に従います。カスタムテーマパッケージはウェブメールで管理します。"
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,7 @@
|
||||
"release_page_pending_desc": "ビルドの進行状況を確認するか、APK を手動でダウンロードします。",
|
||||
"severity_deprecated": "必須",
|
||||
"severity_security": "セキュリティ",
|
||||
"title": "アプリのアップデート"
|
||||
"title": "アプリのアップデート",
|
||||
"verifying": "検証中…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"preview": {
|
||||
"open_external": "앱에서 열기",
|
||||
"pdf_external": "PDF는 이 기기의 PDF 뷰어에서 열려요.",
|
||||
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요."
|
||||
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.",
|
||||
"media_unsupported": "이 파일은 여기서 재생할 수 없습니다.",
|
||||
"pdf_protected": "이 PDF는 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.",
|
||||
"pdf_unreadable": "이 PDF는 여기서 표시할 수 없습니다.",
|
||||
"archive_files": "{count, plural, other {파일 #개}}",
|
||||
"archive_first_files": "처음 {count}개 파일",
|
||||
"archive_protected": "이 압축 파일은 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.",
|
||||
"archive_unreadable": "이 압축 파일은 여기서 열 수 없습니다."
|
||||
},
|
||||
"received_via": "받는 사람: {name}",
|
||||
"remove_color": "태그 제거",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "파일을 열 수 없어요",
|
||||
"open_with": "다른 앱으로 열기…",
|
||||
"preview_too_large": "파일이 너무 커서 미리볼 수 없어요.",
|
||||
"selected_count": "{count, plural, other {#개 선택됨}}",
|
||||
"share_add_people": "사용자 추가",
|
||||
"share_empty": "아직 아무와도 공유하지 않았어요",
|
||||
"share_error": "공유 업데이트에 실패했어요",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "플러그인",
|
||||
"themes": "테마",
|
||||
"updates": "업데이트"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "기본 라이트 및 다크 팔레트예요.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "색상 테마를 선택하세요. 테마는 화면 설정의 라이트/다크 설정을 따르며, 사용자 지정 테마 패키지는 웹메일에서 관리해요.",
|
||||
"title": "테마"
|
||||
"mobile_description": "색상 테마를 선택하세요. 테마는 화면 설정의 라이트/다크 설정을 따르며, 사용자 지정 테마 패키지는 웹메일에서 관리해요."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,7 @@
|
||||
"release_page_pending_desc": "빌드 진행 상황을 확인하거나 APK를 직접 다운로드해요.",
|
||||
"severity_deprecated": "필수",
|
||||
"severity_security": "보안",
|
||||
"title": "앱 업데이트"
|
||||
"title": "앱 업데이트",
|
||||
"verifying": "확인 중…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"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.",
|
||||
"pdf_protected": "Šis PDF ir aizsargāts ar paroli. Atveriet to citā lietotnē, lai ievadītu paroli.",
|
||||
"pdf_unreadable": "Šo PDF šeit nevar parādīt.",
|
||||
"archive_files": "{count, plural, zero {# failu} one {# fails} other {# faili}}",
|
||||
"archive_first_files": "Pirmie {count} faili",
|
||||
"archive_protected": "Šis arhīvs ir aizsargāts ar paroli. Atveriet to citā lietotnē, lai ievadītu paroli.",
|
||||
"archive_unreadable": "Šo arhīvu šeit nevar atvērt."
|
||||
},
|
||||
"received_via": "kam: {name}",
|
||||
"remove_color": "Noņemt tagu",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Neizdevās atvērt failu",
|
||||
"open_with": "Atvērt ar…",
|
||||
"preview_too_large": "Šis fails ir pārāk liels priekšskatīšanai.",
|
||||
"selected_count": "{count, plural, zero {Atlasīti #} one {Atlasīts #} other {Atlasīti #}}",
|
||||
"share_add_people": "Pievienot personas",
|
||||
"share_empty": "Vēl nav kopīgots ne ar vienu",
|
||||
"share_error": "Neizdevās atjaunināt kopīgošanu",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Spraudņi",
|
||||
"themes": "Tēmas",
|
||||
"updates": "Atjauninājumi"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Noklusējuma gaišās un tumšās paletes.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Izvēlieties krāsu tēmu. Tēmas seko gaišā/tumšā režīma iestatījumam sadaļā Izskats; pielāgotas tēmu pakotnes tiek pārvaldītas tīmekļa pastā.",
|
||||
"title": "Tēmas"
|
||||
"mobile_description": "Izvēlieties krāsu tēmu. Tēmas seko gaišā/tumšā režīma iestatījumam sadaļā Izskats; pielāgotas tēmu pakotnes tiek pārvaldītas tīmekļa pastā."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,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…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"preview": {
|
||||
"open_external": "Апп-д нээх",
|
||||
"pdf_external": "PDF файлууд энэ төхөөрөмжийн PDF үзэгчид нээгдэнэ.",
|
||||
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй."
|
||||
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.",
|
||||
"media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй.",
|
||||
"pdf_protected": "Энэ PDF нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.",
|
||||
"pdf_unreadable": "Энэ PDF-ийг энд харуулах боломжгүй.",
|
||||
"archive_files": "{count, plural, one {# файл} other {# файл}}",
|
||||
"archive_first_files": "Эхний {count} файл",
|
||||
"archive_protected": "Энэ архив нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.",
|
||||
"archive_unreadable": "Энэ архивыг энд нээх боломжгүй."
|
||||
},
|
||||
"received_via": "Хэнд: {name}",
|
||||
"remove_color": "Тагийг хасах",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Файлыг нээж чадсангүй",
|
||||
"open_with": "Өөр аппаар нээх…",
|
||||
"preview_too_large": "Энэ файл урьдчилан харахад хэт том байна.",
|
||||
"selected_count": "{count, plural, one {Сонгосон: #} other {Сонгосон: #}}",
|
||||
"share_add_people": "Хүн нэмэх",
|
||||
"share_empty": "Одоохондоо хэнтэй ч хуваалцаагүй",
|
||||
"share_error": "Хуваалцалтыг шинэчилж чадсангүй",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Плагинууд",
|
||||
"themes": "Өнгөний загварууд",
|
||||
"updates": "Шинэчлэлт"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Өгөгдмөл цайвар ба харанхуй палитр.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Өнгөний загвар сонгоно уу. Өнгөний загварууд Гадаад төрх хэсгийн цайвар/харанхуй тохиргоог дагана; өөрийн загварын багцуудыг вэбмэйлд удирдана.",
|
||||
"title": "Өнгөний загварууд"
|
||||
"mobile_description": "Өнгөний загвар сонгоно уу. Өнгөний загварууд Гадаад төрх хэсгийн цайвар/харанхуй тохиргоог дагана; өөрийн загварын багцуудыг вэбмэйлд удирдана."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,7 @@
|
||||
"release_page_pending_desc": "Бүтээлтийн явцыг хянах эсвэл APK-г гараар татаж авах.",
|
||||
"severity_deprecated": "Заавал",
|
||||
"severity_security": "Аюулгүй байдал",
|
||||
"title": "Аппын шинэчлэл"
|
||||
"title": "Аппын шинэчлэл",
|
||||
"verifying": "Шалгаж байна…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"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.",
|
||||
"pdf_protected": "Denne PDF-en er passordbeskyttet. Åpne den i en annen app for å skrive inn passordet.",
|
||||
"pdf_unreadable": "Denne PDF-en kan ikke vises her.",
|
||||
"archive_files": "{count, plural, one {# fil} other {# filer}}",
|
||||
"archive_first_files": "De første {count} filene",
|
||||
"archive_protected": "Dette arkivet er passordbeskyttet. Åpne det i en annen app for å skrive inn passordet.",
|
||||
"archive_unreadable": "Dette arkivet kan ikke åpnes her."
|
||||
},
|
||||
"received_via": "til {name}",
|
||||
"remove_color": "Fjern etiketten",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Kunne ikke åpne filen",
|
||||
"open_with": "Åpne med…",
|
||||
"preview_too_large": "Denne filen er for stor til å forhåndsvises.",
|
||||
"selected_count": "{count, plural, one {# valgt} other {# valgte}}",
|
||||
"share_add_people": "Legg til personer",
|
||||
"share_empty": "Ikke delt med noen ennå",
|
||||
"share_error": "Kunne ikke oppdatere deling",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Programtillegg",
|
||||
"themes": "Temaer",
|
||||
"updates": "Oppdateringer"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Standardpalettene for lyst og mørkt.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Velg et fargetema. Temaene følger innstillingen for lyst/mørkt under Utseende; egendefinerte temapakker administreres i nettposten.",
|
||||
"title": "Temaer"
|
||||
"mobile_description": "Velg et fargetema. Temaene følger innstillingen for lyst/mørkt under Utseende; egendefinerte temapakker administreres i nettposten."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,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…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"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.",
|
||||
"pdf_protected": "Deze pdf is beveiligd met een wachtwoord. Open hem in een andere app om het wachtwoord in te voeren.",
|
||||
"pdf_unreadable": "Deze pdf kan hier niet worden weergegeven.",
|
||||
"archive_files": "{count, plural, one {# bestand} other {# bestanden}}",
|
||||
"archive_first_files": "De eerste {count} bestanden",
|
||||
"archive_protected": "Dit archief is beveiligd met een wachtwoord. Open het in een andere app om het wachtwoord in te voeren.",
|
||||
"archive_unreadable": "Dit archief kan hier niet worden geopend."
|
||||
},
|
||||
"received_via": "aan {name}",
|
||||
"remove_color": "Label verwijderen",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Kon het bestand niet openen",
|
||||
"open_with": "Openen met…",
|
||||
"preview_too_large": "Dit bestand is te groot voor een voorbeeld.",
|
||||
"selected_count": "{count, plural, one {# geselecteerd} other {# geselecteerd}}",
|
||||
"share_add_people": "Personen toevoegen",
|
||||
"share_empty": "Nog met niemand gedeeld",
|
||||
"share_error": "Delen kon niet worden bijgewerkt",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Plug-ins",
|
||||
"themes": "Thema's",
|
||||
"updates": "Updates"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "De standaard lichte en donkere paletten.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Kies een kleurthema. Thema's volgen de instelling licht/donker onder Uiterlijk; aangepaste themapakketten worden beheerd in de webmail.",
|
||||
"title": "Thema's"
|
||||
"mobile_description": "Kies een kleurthema. Thema's volgen de instelling licht/donker onder Uiterlijk; aangepaste themapakketten worden beheerd in de webmail."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,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…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"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ć.",
|
||||
"pdf_protected": "Ten plik PDF jest chroniony hasłem. Otwórz go w innej aplikacji, aby wpisać hasło.",
|
||||
"pdf_unreadable": "Tego pliku PDF nie można tutaj wyświetlić.",
|
||||
"archive_files": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}",
|
||||
"archive_first_files": "Pierwsze {count} plików",
|
||||
"archive_protected": "To archiwum jest chronione hasłem. Otwórz je w innej aplikacji, aby wpisać hasło.",
|
||||
"archive_unreadable": "Tego archiwum nie można tutaj otworzyć."
|
||||
},
|
||||
"received_via": "do {name}",
|
||||
"remove_color": "Usuń etykietę",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Nie udało się otworzyć pliku",
|
||||
"open_with": "Otwórz za pomocą…",
|
||||
"preview_too_large": "Ten plik jest zbyt duży, aby wyświetlić podgląd.",
|
||||
"selected_count": "{count, plural, one {# zaznaczony} few {# zaznaczone} many {# zaznaczonych} other {# zaznaczonego}}",
|
||||
"share_add_people": "Dodaj osoby",
|
||||
"share_empty": "Jeszcze nikomu nie udostępniono",
|
||||
"share_error": "Nie udało się zaktualizować udostępniania",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Wtyczki",
|
||||
"themes": "Motywy",
|
||||
"updates": "Aktualizacje"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Domyślne jasne i ciemne palety kolorów.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Wybierz motyw kolorystyczny. Motywy stosują ustawienie jasny/ciemny z sekcji Wygląd; własnymi pakietami motywów zarządza się w webmailu.",
|
||||
"title": "Motywy"
|
||||
"mobile_description": "Wybierz motyw kolorystyczny. Motywy stosują ustawienie jasny/ciemny z sekcji Wygląd; własnymi pakietami motywów zarządza się w webmailu."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,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…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"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.",
|
||||
"pdf_protected": "Este PDF está protegido por palavra-passe. Abra-o noutra aplicação para a introduzir.",
|
||||
"pdf_unreadable": "Este PDF não pode ser mostrado aqui.",
|
||||
"archive_files": "{count, plural, one {# ficheiro} other {# ficheiros}}",
|
||||
"archive_first_files": "Os primeiros {count} ficheiros",
|
||||
"archive_protected": "Este arquivo está protegido por palavra-passe. Abra-o noutra aplicação para a introduzir.",
|
||||
"archive_unreadable": "Este arquivo não pode ser aberto aqui."
|
||||
},
|
||||
"received_via": "para {name}",
|
||||
"remove_color": "Remover etiqueta",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Não foi possível abrir o arquivo",
|
||||
"open_with": "Abrir com…",
|
||||
"preview_too_large": "Este arquivo é grande demais para pré-visualizar.",
|
||||
"selected_count": "{count, plural, one {# selecionado} other {# selecionados}}",
|
||||
"share_add_people": "Adicionar pessoas",
|
||||
"share_empty": "Ainda não compartilhado com ninguém",
|
||||
"share_error": "Falha ao atualizar o compartilhamento",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Plugins",
|
||||
"themes": "Temas",
|
||||
"updates": "Atualizações"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "As paletas clara e escura padrão.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Escolha um tema de cores. Os temas seguem a configuração clara/escura de Aparência; pacotes de temas personalizados são gerenciados no webmail.",
|
||||
"title": "Temas"
|
||||
"mobile_description": "Escolha um tema de cores. Os temas seguem a configuração clara/escura de Aparência; pacotes de temas personalizados são gerenciados no webmail."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,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…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"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.",
|
||||
"pdf_protected": "Acest PDF este protejat cu parolă. Deschide-l în altă aplicație pentru a introduce parola.",
|
||||
"pdf_unreadable": "Acest PDF nu poate fi afișat aici.",
|
||||
"archive_files": "{count, plural, one {# fișier} few {# fișiere} other {# de fișiere}}",
|
||||
"archive_first_files": "Primele {count} fișiere",
|
||||
"archive_protected": "Această arhivă este protejată cu parolă. Deschide-o în altă aplicație pentru a introduce parola.",
|
||||
"archive_unreadable": "Această arhivă nu poate fi deschisă aici."
|
||||
},
|
||||
"received_via": "către {name}",
|
||||
"remove_color": "Eliminați eticheta",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Fișierul nu a putut fi deschis",
|
||||
"open_with": "Deschideți cu…",
|
||||
"preview_too_large": "Acest fișier este prea mare pentru previzualizare.",
|
||||
"selected_count": "{count, plural, one {# element selectat} few {# elemente selectate} other {# de elemente selectate}}",
|
||||
"share_add_people": "Adăugați persoane",
|
||||
"share_empty": "Nu a fost partajat încă cu nimeni",
|
||||
"share_error": "Nu s-a putut actualiza partajarea",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Pluginuri",
|
||||
"themes": "Teme",
|
||||
"updates": "Actualizări"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Paletele implicite, luminoasă și întunecată.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Alegeți o temă de culori. Temele urmează setarea luminos/întunecat din Aspect; pachetele de teme personalizate se gestionează în webmail.",
|
||||
"title": "Teme"
|
||||
"mobile_description": "Alegeți o temă de culori. Temele urmează setarea luminos/întunecat din Aspect; pachetele de teme personalizate se gestionează în webmail."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,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ă…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"preview": {
|
||||
"open_external": "Открыть в приложении",
|
||||
"pdf_external": "PDF-файлы открываются в программе просмотра PDF на этом устройстве.",
|
||||
"unsupported": "Для этого типа файлов нет предпросмотра."
|
||||
"unsupported": "Для этого типа файлов нет предпросмотра.",
|
||||
"media_unsupported": "Этот файл нельзя воспроизвести здесь.",
|
||||
"pdf_protected": "Этот PDF защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.",
|
||||
"pdf_unreadable": "Этот PDF нельзя показать здесь.",
|
||||
"archive_files": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}",
|
||||
"archive_first_files": "Первые {count} файлов",
|
||||
"archive_protected": "Этот архив защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.",
|
||||
"archive_unreadable": "Этот архив нельзя открыть здесь."
|
||||
},
|
||||
"received_via": "на {name}",
|
||||
"remove_color": "Убрать тег",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Не удалось открыть файл",
|
||||
"open_with": "Открыть с помощью…",
|
||||
"preview_too_large": "Файл слишком большой для предпросмотра.",
|
||||
"selected_count": "{count, plural, one {Выбрано: #} few {Выбрано: #} many {Выбрано: #} other {Выбрано: #}}",
|
||||
"share_add_people": "Добавить людей",
|
||||
"share_empty": "Пока никому не предоставлен доступ",
|
||||
"share_error": "Не удалось обновить общий доступ",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Плагины",
|
||||
"themes": "Темы",
|
||||
"updates": "Обновления"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Стандартные светлая и тёмная палитры.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Выберите цветовую тему. Темы учитывают светлый или тёмный режим из раздела «Внешний вид»; собственными пакетами тем можно управлять в веб-почте.",
|
||||
"title": "Темы"
|
||||
"mobile_description": "Выберите цветовую тему. Темы учитывают светлый или тёмный режим из раздела «Внешний вид»; собственными пакетами тем можно управлять в веб-почте."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,7 @@
|
||||
"release_page_pending_desc": "Следите за ходом сборки или скачайте APK вручную.",
|
||||
"severity_deprecated": "Обязательное",
|
||||
"severity_security": "Безопасность",
|
||||
"title": "Обновления приложения"
|
||||
"title": "Обновления приложения",
|
||||
"verifying": "Проверка…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"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ť.",
|
||||
"pdf_protected": "Toto PDF je chránené heslom. Otvorte ho v inej aplikácii a zadajte heslo.",
|
||||
"pdf_unreadable": "Toto PDF tu nie je možné zobraziť.",
|
||||
"archive_files": "{count, plural, one {# súbor} few {# súbory} many {# súboru} other {# súborov}}",
|
||||
"archive_first_files": "Prvých {count} súborov",
|
||||
"archive_protected": "Tento archív je chránený heslom. Otvorte ho v inej aplikácii a zadajte heslo.",
|
||||
"archive_unreadable": "Tento archív tu nie je možné otvoriť."
|
||||
},
|
||||
"received_via": "pre {name}",
|
||||
"remove_color": "Odstrániť štítok",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Súbor sa nepodarilo otvoriť",
|
||||
"open_with": "Otvoriť pomocou…",
|
||||
"preview_too_large": "Tento súbor je príliš veľký na náhľad.",
|
||||
"selected_count": "{count, plural, one {# vybraná} few {# vybrané} many {# vybranej} other {# vybraných}}",
|
||||
"share_add_people": "Pridať osoby",
|
||||
"share_empty": "Zatiaľ nikomu nezdieľané",
|
||||
"share_error": "Aktualizácia zdieľania zlyhala",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Pluginy",
|
||||
"themes": "Témy",
|
||||
"updates": "Aktualizácie"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Predvolené svetlé a tmavé palety.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Vyberte farebnú tému. Témy sa riadia nastavením svetlý/tmavý vo Vzhľade; vlastné balíky tém sa spravujú vo webmaile.",
|
||||
"title": "Témy"
|
||||
"mobile_description": "Vyberte farebnú tému. Témy sa riadia nastavením svetlý/tmavý vo Vzhľade; vlastné balíky tém sa spravujú vo webmaile."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,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…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"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.",
|
||||
"pdf_protected": "Bu PDF parola korumalı. Parolayı girmek için başka bir uygulamada açın.",
|
||||
"pdf_unreadable": "Bu PDF burada gösterilemiyor.",
|
||||
"archive_files": "{count, plural, one {# dosya} other {# dosya}}",
|
||||
"archive_first_files": "İlk {count} dosya",
|
||||
"archive_protected": "Bu arşiv parola korumalı. Parolayı girmek için başka bir uygulamada açın.",
|
||||
"archive_unreadable": "Bu arşiv burada açılamıyor."
|
||||
},
|
||||
"received_via": "alıcı: {name}",
|
||||
"remove_color": "Etiketi kaldır",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Dosya açılamadı",
|
||||
"open_with": "Birlikte aç…",
|
||||
"preview_too_large": "Bu dosya, önizleme için çok büyük.",
|
||||
"selected_count": "{count, plural, one {# seçildi} other {# seçildi}}",
|
||||
"share_add_people": "Kişi ekle",
|
||||
"share_empty": "Henüz kimseyle paylaşılmadı",
|
||||
"share_error": "Paylaşım güncellenemedi",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Eklentiler",
|
||||
"themes": "Temalar",
|
||||
"updates": "Güncellemeler"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Varsayılan açık ve koyu paletler.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Bir renk teması seçin. Temalar Görünüm'deki açık/koyu ayarını izler; özel tema paketleri webmail'de yönetilir.",
|
||||
"title": "Temalar"
|
||||
"mobile_description": "Bir renk teması seçin. Temalar Görünüm'deki açık/koyu ayarını izler; özel tema paketleri webmail'de yönetilir."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,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…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"preview": {
|
||||
"open_external": "Відкрити в застосунку",
|
||||
"pdf_external": "PDF-файли відкриваються в переглядачі PDF на цьому пристрої.",
|
||||
"unsupported": "Для цього типу файлів немає попереднього перегляду."
|
||||
"unsupported": "Для цього типу файлів немає попереднього перегляду.",
|
||||
"media_unsupported": "Цей файл не можна відтворити тут.",
|
||||
"pdf_protected": "Цей PDF захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.",
|
||||
"pdf_unreadable": "Цей PDF не можна показати тут.",
|
||||
"archive_files": "{count, plural, one {# файл} few {# файли} many {# файлів} other {# файлу}}",
|
||||
"archive_first_files": "Перші {count} файлів",
|
||||
"archive_protected": "Цей архів захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.",
|
||||
"archive_unreadable": "Цей архів не можна відкрити тут."
|
||||
},
|
||||
"received_via": "на {name}",
|
||||
"remove_color": "Прибрати тег",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "Не вдалося відкрити файл",
|
||||
"open_with": "Відкрити за допомогою…",
|
||||
"preview_too_large": "Файл завеликий для попереднього перегляду.",
|
||||
"selected_count": "{count, plural, one {Вибрано: #} few {Вибрано: #} many {Вибрано: #} other {Вибрано: #}}",
|
||||
"share_add_people": "Додати людей",
|
||||
"share_empty": "Поки що ні з ким не поділено",
|
||||
"share_error": "Не вдалося оновити спільний доступ",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Плагіни",
|
||||
"themes": "Теми",
|
||||
"updates": "Оновлення"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Стандартні світла й темна палітри.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Виберіть кольорову тему. Теми враховують світлий або темний режим із розділу «Зовнішній вигляд»; власними пакетами тем можна керувати у веб-пошті.",
|
||||
"title": "Теми"
|
||||
"mobile_description": "Виберіть кольорову тему. Теми враховують світлий або темний режим із розділу «Зовнішній вигляд»; власними пакетами тем можна керувати у веб-пошті."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,7 @@
|
||||
"release_page_pending_desc": "Стежте за перебігом збирання або завантажте APK вручну.",
|
||||
"severity_deprecated": "Обов'язкове",
|
||||
"severity_security": "Безпека",
|
||||
"title": "Оновлення застосунку"
|
||||
"title": "Оновлення застосунку",
|
||||
"verifying": "Перевірка…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"preview": {
|
||||
"open_external": "在應用程式中開啟",
|
||||
"pdf_external": "PDF 會在此裝置的 PDF 檢視器中開啟。",
|
||||
"unsupported": "無法預覽此檔案類型。"
|
||||
"unsupported": "無法預覽此檔案類型。",
|
||||
"media_unsupported": "無法在此播放這個檔案。",
|
||||
"pdf_protected": "此 PDF 受密碼保護。請在其他應用程式中開啟以輸入密碼。",
|
||||
"pdf_unreadable": "無法在此顯示這個 PDF。",
|
||||
"archive_files": "{count, plural, other {# 個檔案}}",
|
||||
"archive_first_files": "前 {count} 個檔案",
|
||||
"archive_protected": "此壓縮檔受密碼保護。請在其他應用程式中開啟以輸入密碼。",
|
||||
"archive_unreadable": "無法在此開啟這個壓縮檔。"
|
||||
},
|
||||
"received_via": "寄給 {name}",
|
||||
"remove_color": "移除標籤",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "無法開啟檔案",
|
||||
"open_with": "以其他應用程式開啟…",
|
||||
"preview_too_large": "此檔案太大,無法預覽。",
|
||||
"selected_count": "{count, plural, other {已選取 # 項}}",
|
||||
"share_add_people": "新增使用者",
|
||||
"share_empty": "尚未與任何人分享",
|
||||
"share_error": "無法更新分享設定",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "外掛程式",
|
||||
"themes": "主題",
|
||||
"updates": "更新"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "預設的淺色與深色配色。",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "選擇色彩主題。主題會依照「外觀」中的淺色/深色設定;自訂主題套件請在網頁郵件中管理。",
|
||||
"title": "主題"
|
||||
"mobile_description": "選擇色彩主題。主題會依照「外觀」中的淺色/深色設定;自訂主題套件請在網頁郵件中管理。"
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,7 @@
|
||||
"release_page_pending_desc": "查看建置進度或手動下載 APK。",
|
||||
"severity_deprecated": "必要",
|
||||
"severity_security": "安全性",
|
||||
"title": "應用程式更新"
|
||||
"title": "應用程式更新",
|
||||
"verifying": "正在驗證…"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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,14 @@
|
||||
"preview": {
|
||||
"open_external": "在应用中打开",
|
||||
"pdf_external": "PDF 将在此设备的 PDF 查看器中打开。",
|
||||
"unsupported": "无法预览此类文件。"
|
||||
"unsupported": "无法预览此类文件。",
|
||||
"media_unsupported": "无法在此播放该文件。",
|
||||
"pdf_protected": "此 PDF 受密码保护。请在其他应用中打开以输入密码。",
|
||||
"pdf_unreadable": "无法在此显示该 PDF。",
|
||||
"archive_files": "{count, plural, other {# 个文件}}",
|
||||
"archive_first_files": "前 {count} 个文件",
|
||||
"archive_protected": "此压缩包受密码保护。请在其他应用中打开以输入密码。",
|
||||
"archive_unreadable": "无法在此打开该压缩包。"
|
||||
},
|
||||
"received_via": "发给 {name}",
|
||||
"remove_color": "删除标签",
|
||||
@@ -429,7 +445,6 @@
|
||||
"open_error": "无法打开文件",
|
||||
"open_with": "打开方式…",
|
||||
"preview_too_large": "此文件过大,无法预览。",
|
||||
"selected_count": "{count, plural, other {已选择 # 项}}",
|
||||
"share_add_people": "添加用户",
|
||||
"share_empty": "尚未与任何人共享",
|
||||
"share_error": "更新共享失败",
|
||||
@@ -1086,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "插件",
|
||||
"themes": "主题",
|
||||
"updates": "更新"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1103,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "默认的浅色和深色配色。",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "选择颜色主题。主题会跟随“外观”中的浅色/深色设置;自定义主题包需在网页邮箱中管理。",
|
||||
"title": "主题"
|
||||
"mobile_description": "选择颜色主题。主题会跟随“外观”中的浅色/深色设置;自定义主题包需在网页邮箱中管理。"
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
@@ -1168,6 +1180,7 @@
|
||||
"release_page_pending_desc": "查看构建进度或手动下载 APK。",
|
||||
"severity_deprecated": "必需",
|
||||
"severity_security": "安全",
|
||||
"title": "应用更新"
|
||||
"title": "应用更新",
|
||||
"verifying": "正在验证…"
|
||||
}
|
||||
}
|
||||
|
||||
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
@@ -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.71",
|
||||
"version": "0.1.83",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.83",
|
||||
"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.71",
|
||||
"version": "0.1.83",
|
||||
"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,242 @@
|
||||
// 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',
|
||||
BellOff: 'bell-off',
|
||||
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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+522
-395
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user