Compare commits

..
8 Commits
148 changed files with 8848 additions and 55 deletions
+9 -1
View File
@@ -57,7 +57,12 @@ jobs:
java-version: 17
cache: gradle
# The action installs the legacy `tools` package by default, which the
# runner's cmdline-tools 16 no longer carries ("Failed to find package
# 'tools'"). Gradle fetches the platforms and build-tools it needs.
- uses: android-actions/setup-android@v3
with:
packages: platform-tools
- name: Cache Android NDK / CMake
uses: actions/cache@v4
@@ -205,9 +210,12 @@ jobs:
set -euo pipefail
APK=android/app/build/outputs/apk/release/app-release.apk
apksigner verify --verbose --print-certs "$APK"
# build-tools 36 prints "Signer #1 certificate SHA-256 digest: …",
# 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …".
signers() {
apksigner verify --print-certs "$@" "$APK" \
| sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p'
| sed -n -E 's/^(Signer #[0-9]+|V[0-9.]+ Signer:) certificate SHA-256 digest: //p' \
| sort -u
}
MODERN=$(signers --min-sdk-version 28)
LEGACY=$(signers --max-sdk-version 27)
+29 -4
View File
@@ -58,10 +58,11 @@ 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 { prefetchMessage } from './src/lib/email-detail-cache';
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 { toast } from './src/stores/toast-store';
import { useNetworkStore } from './src/stores/network-store';
import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
@@ -70,6 +71,8 @@ import { ToastHost } from './src/components/ToastHost';
import { UndoSnackbar } from './src/components/UndoSnackbar';
import { AppIconBadge } from './src/components/AppIconBadge';
import { getEmails } from './src/api/email';
import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
import { draftContextFromEmail } from './src/lib/draft-context';
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
@@ -131,16 +134,27 @@ async function openCalendarReminder(target: CalendarReminderTarget): Promise<voi
// Deep links (bulwarkmobile://, webmail https permalinks, mailto:) and
// Android share-sheet payloads all end up here once the navigator is ready.
async function openDeepLink(link: DeepLink): Promise<void> {
await handleDeepLink(link, {
const opened = await handleDeepLink(link, {
navigation: navigationRef,
resolveThreadId: async (emailId) => {
resolveThreadId: async (emailId, jmapAccountId) => {
try {
const [email] = await getEmails([emailId]);
const [email] = await getEmails([emailId], jmapAccountId);
return email?.threadId ?? null;
} catch {
return null;
}
},
openDraft: async (emailId, jmapAccountId) => {
try {
// Cache first, so a draft read before still opens offline.
const draft = draftContextFromEmail(await loadDetail(emailId, jmapAccountId), jmapAccountId);
if (!navigationRef.isReady()) return false;
navigationRef.navigate('Compose', { draft });
return true;
} catch {
return false;
}
},
switchAccount: async (accountId) => {
const auth = useAuthStore.getState();
if (auth.activeAccountId === accountId) return true;
@@ -149,6 +163,9 @@ async function openDeepLink(link: DeepLink): Promise<void> {
return useAuthStore.getState().activeAccountId === accountId;
},
});
// A widget or a link elsewhere brought the app up for this; say it failed
// rather than leave the user wondering why the app just opened.
if (!opened) toast.error(useLocaleStore.getState().t('deep_link.open_failed', "Couldn't open this"));
}
// Toasts and the undo bar show on whichever stack screen is up, so a failure
@@ -385,6 +402,14 @@ export default function App() {
return useNetworkStore.getState().init();
}, []);
// Home-screen widgets follow the signed-in data; once the last account is
// gone they are wiped so no mail stays visible on the launcher.
React.useEffect(() => {
if (isAuthenticated) return startWidgetSync();
if (hasRestoredSession) void signOutWidgets();
return undefined;
}, [isAuthenticated, hasRestoredSession]);
// When the network flips back on while we're authenticated-but-offline
// (no live JMAP session), retry the session so the user lands back on
// live data without needing to relaunch.
+1 -1
View File
@@ -1 +1 @@
0.1.63
0.1.66
+227
View File
@@ -66,5 +66,232 @@
</intent-filter>
</service>
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/>
<!-- BEGIN WIDGETS: generated by scripts/sync-android-widgets.mjs, do not edit by hand -->
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/>
</receiver>
<receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/>
</receiver>
<receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/>
</receiver>
<receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/>
</receiver>
<receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/>
</receiver>
<receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/>
</receiver>
<receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/>
</receiver>
<receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/>
</receiver>
<receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/>
</receiver>
<receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/>
</receiver>
<receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/>
</receiver>
<receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/>
</receiver>
<receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/>
</receiver>
<receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/>
</receiver>
<receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/>
</receiver>
<receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/>
</receiver>
<receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/>
</receiver>
<receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/>
</receiver>
<receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/>
</receiver>
<receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/>
</receiver>
<receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/>
</receiver>
<receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/>
</receiver>
<receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/>
</receiver>
<receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/>
</receiver>
<receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/>
</receiver>
<receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/>
</receiver>
<receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/>
</receiver>
<receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/>
</receiver>
<receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/>
</receiver>
<receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/>
</receiver>
<receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/>
</receiver>
<receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/>
</receiver>
<!-- END WIDGETS -->
</application>
</manifest>
@@ -0,0 +1,40 @@
// Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json.
// react-native-android-widget names a widget after its provider class, so each
// widget needs its own subclass; the JS side (src/widgets/task-handler.tsx)
// renders by that name.
package com.anonymous.bulwarkmobile.widget
import com.reactnativeandroidwidget.RNWidgetProvider
class InboxWidget : RNWidgetProvider()
class TriageWidget : RNWidgetProvider()
class UnreadCountWidget : RNWidgetProvider()
class LatestMessageWidget : RNWidgetProvider()
class FolderCountsWidget : RNWidgetProvider()
class MailShortcutsWidget : RNWidgetProvider()
class AllAccountsWidget : RNWidgetProvider()
class StarredWidget : RNWidgetProvider()
class FavouritePeopleWidget : RNWidgetProvider()
class MailSearchWidget : RNWidgetProvider()
class OutboxWidget : RNWidgetProvider()
class TagWidget : RNWidgetProvider()
class LastDraftWidget : RNWidgetProvider()
class AgendaWidget : RNWidgetProvider()
class WeekWidget : RNWidgetProvider()
class MonthWidget : RNWidgetProvider()
class TodayEventsWidget : RNWidgetProvider()
class NextEventWidget : RNWidgetProvider()
class DateNextWidget : RNWidgetProvider()
class UpNextWidget : RNWidgetProvider()
class InvitationsWidget : RNWidgetProvider()
class FreeTimeWidget : RNWidgetProvider()
class TasksWidget : RNWidgetProvider()
class TaskProgressWidget : RNWidgetProvider()
class CountdownWidget : RNWidgetProvider()
class BirthdaysWidget : RNWidgetProvider()
class TodayHubWidget : RNWidgetProvider()
class MailAndNextWidget : RNWidgetProvider()
class RecentFilesWidget : RNWidgetProvider()
class AttachmentsWidget : RNWidgetProvider()
class VacationWidget : RNWidgetProvider()
class StorageWidget : RNWidgetProvider()
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Home-screen widget edge, drawn over the widget by layout/rn_widget.xml. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke
android:width="1dp"
android:color="@color/widget_frame" />
<corners android:radius="@dimen/widget_radius" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Home-screen widget root (layout/rn_widget.xml): its rounded outline is what the widget clips to. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_surface" />
<corners android:radius="@dimen/widget_radius" />
</shape>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Night twin of layout/rn_widget.xml, which explains the override. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@android:id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_surface"
android:clipToOutline="true"
android:foreground="@drawable/widget_frame"
tools:targetApi="31">
<ImageView
android:id="@+id/rn_widget_image_light"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="gone" />
<ImageView
android:id="@+id/rn_widget_image_dark"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="visible" />
<FrameLayout
android:id="@+id/rn_widget_clickable_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/rn_widget_collection_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Overrides react-native-android-widget's rn_widget.xml (same ids and children;
layout-night/rn_widget.xml is its night twin).
The library draws each widget into one bitmap sized from the size the
launcher reports, and pins it to the top left without scaling. When the real
frame is smaller (after a rotation, or on a launcher that over-reports), the
bitmap's bottom edge is cut off and takes the rounded corners and the border
with it, so the bottom corners fall back to whatever the launcher clips to.
This root always has the real size, so it owns the shape instead: it clips to
a rounded outline (Android 12+; the launcher then leaves the corners to us),
fills in where the bitmap falls short, and draws the frame on top.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@android:id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_surface"
android:clipToOutline="true"
android:foreground="@drawable/widget_frame"
tools:targetApi="31">
<ImageView
android:id="@+id/rn_widget_image_light"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="visible" />
<ImageView
android:id="@+id/rn_widget_image_dark"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="gone" />
<FrameLayout
android:id="@+id/rn_widget_clickable_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/rn_widget_collection_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
@@ -3,4 +3,6 @@
<color name="splashscreen_background">#09090b</color>
<!-- Matches the dark palette background (src/theme/tokens.ts). -->
<color name="window_background">#09090b</color>
<!-- DARK.bg in src/widgets/theme.ts. -->
<color name="widget_surface">#0a0a0a</color>
</resources>
@@ -6,4 +6,8 @@
<color name="colorPrimaryDark">#09090b</color>
<!-- Behind the React root until the first frame; matches the light palette background (src/theme/tokens.ts). -->
<color name="window_background">#ffffff</color>
<!-- Home-screen widget surface (LIGHT.bg in src/widgets/theme.ts); it only shows where the drawn widget falls short of the launcher's frame. -->
<color name="widget_surface">#ffffff</color>
<!-- Home-screen widget edge, one colour for both schemes: the app can force a theme the launcher's night mode doesn't know about. -->
<color name="widget_frame">#4d808080</color>
</resources>
@@ -0,0 +1,4 @@
<resources>
<!-- Home-screen widget corners; WIDGET_RADIUS in src/widgets/primitives.tsx. -->
<dimen name="widget_radius">22dp</dimen>
</resources>
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<resources>
<string name="widgetprovider_inboxwidget_label">Inbox</string>
<string name="widgetprovider_inboxwidget_description">Newest messages with sender, time and subject. Taller sizes add avatars and a line of preview.</string>
<string name="widgetprovider_triagewidget_label">Triage</string>
<string name="widgetprovider_triagewidget_description">One unread message at a time with archive, delete and reply buttons.</string>
<string name="widgetprovider_unreadcountwidget_label">Unread count</string>
<string name="widgetprovider_unreadcountwidget_description">Unread count for the inbox, with the latest senders.</string>
<string name="widgetprovider_latestmessagewidget_label">Latest message</string>
<string name="widgetprovider_latestmessagewidget_description">Only the newest unread message.</string>
<string name="widgetprovider_foldercountswidget_label">Folder counts</string>
<string name="widgetprovider_foldercountswidget_description">Unread counts for up to four folders.</string>
<string name="widgetprovider_mailshortcutswidget_label">Shortcuts</string>
<string name="widgetprovider_mailshortcutswidget_description">Compose, calendar, contacts and files.</string>
<string name="widgetprovider_allaccountswidget_label">All accounts</string>
<string name="widgetprovider_allaccountswidget_description">The unified inbox across every signed-in account.</string>
<string name="widgetprovider_starredwidget_label">Starred</string>
<string name="widgetprovider_starredwidget_description">Starred messages, newest first.</string>
<string name="widgetprovider_favouritepeoplewidget_label">Favourite people</string>
<string name="widgetprovider_favouritepeoplewidget_description">The people you hear from most, with their unread count.</string>
<string name="widgetprovider_mailsearchwidget_label">Search mail</string>
<string name="widgetprovider_mailsearchwidget_description">Search bar with your recent searches.</string>
<string name="widgetprovider_outboxwidget_label">Scheduled and outbox</string>
<string name="widgetprovider_outboxwidget_description">Scheduled messages and anything waiting in the outbox.</string>
<string name="widgetprovider_tagwidget_label">Tag</string>
<string name="widgetprovider_tagwidget_description">Unread count for your busiest tag.</string>
<string name="widgetprovider_lastdraftwidget_label">Last draft</string>
<string name="widgetprovider_lastdraftwidget_description">Back into the draft you left.</string>
<string name="widgetprovider_agendawidget_label">Agenda</string>
<string name="widgetprovider_agendawidget_description">Upcoming events by day with time, colour and place.</string>
<string name="widgetprovider_weekwidget_label">This week</string>
<string name="widgetprovider_weekwidget_description">The week at a glance from 8:00 to 18:00.</string>
<string name="widgetprovider_monthwidget_label">Month</string>
<string name="widgetprovider_monthwidget_description">Month grid with a dot per calendar and the next two events.</string>
<string name="widgetprovider_todayeventswidget_label">Today</string>
<string name="widgetprovider_todayeventswidget_description">Today\'s date with the rest of today and tomorrow.</string>
<string name="widgetprovider_nexteventwidget_label">Next event</string>
<string name="widgetprovider_nexteventwidget_description">What\'s next, when and where.</string>
<string name="widgetprovider_datenextwidget_label">Date and next two</string>
<string name="widgetprovider_datenextwidget_description">Today\'s date with the next two events.</string>
<string name="widgetprovider_upnextwidget_label">Up next</string>
<string name="widgetprovider_upnextwidget_description">Countdown to the next event with join and running-late buttons.</string>
<string name="widgetprovider_invitationswidget_label">Invitations</string>
<string name="widgetprovider_invitationswidget_description">Pending invitations with accept, maybe and decline.</string>
<string name="widgetprovider_freetimewidget_label">Free time</string>
<string name="widgetprovider_freetimewidget_description">When you\'re free this week.</string>
<string name="widgetprovider_taskswidget_label">Tasks</string>
<string name="widgetprovider_taskswidget_description">Open tasks with tick boxes that work from the home screen.</string>
<string name="widgetprovider_taskprogresswidget_label">Task progress</string>
<string name="widgetprovider_taskprogresswidget_description">How many tasks are done.</string>
<string name="widgetprovider_countdownwidget_label">Countdown</string>
<string name="widgetprovider_countdownwidget_description">Days until your next all-day event.</string>
<string name="widgetprovider_birthdayswidget_label">Birthdays</string>
<string name="widgetprovider_birthdayswidget_description">Upcoming birthdays from your contacts.</string>
<string name="widgetprovider_todayhubwidget_label">Today overview</string>
<string name="widgetprovider_todayhubwidget_description">Next event, newest unread mail and tasks due, in one widget.</string>
<string name="widgetprovider_mailandnextwidget_label">Mail and next event</string>
<string name="widgetprovider_mailandnextwidget_description">Unread mail on one side, the next event on the other.</string>
<string name="widgetprovider_recentfileswidget_label">Recent files</string>
<string name="widgetprovider_recentfileswidget_description">Recently changed items in Files.</string>
<string name="widgetprovider_attachmentswidget_label">Recent attachments</string>
<string name="widgetprovider_attachmentswidget_description">The newest attachments from your mail.</string>
<string name="widgetprovider_vacationwidget_label">Vacation responder</string>
<string name="widgetprovider_vacationwidget_description">Vacation responder status with an on/off switch.</string>
<string name="widgetprovider_storagewidget_label">Storage</string>
<string name="widgetprovider_storagewidget_description">How much of your mailbox quota is used.</string>
</resources>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="250dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="4"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_agendawidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/agendawidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_allaccountswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/allaccountswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_attachmentswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/attachmentswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_birthdayswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/birthdayswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_countdownwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/countdownwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_datenextwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/datenextwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_favouritepeoplewidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/favouritepeoplewidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_foldercountswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/foldercountswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_freetimewidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/freetimewidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_inboxwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/inboxwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_invitationswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/invitationswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_lastdraftwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/lastdraftwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_latestmessagewidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/latestmessagewidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_mailandnextwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/mailandnextwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="40dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_mailsearchwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/mailsearchwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_mailshortcutswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/mailshortcutswidget_preview"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="250dp"
android:minResizeWidth="180dp"
android:minResizeHeight="180dp"
android:targetCellWidth="4"
android:targetCellHeight="4"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_monthwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/monthwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_nexteventwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/nexteventwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_outboxwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/outboxwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_recentfileswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/recentfileswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_starredwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/starredwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_storagewidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/storagewidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_tagwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/tagwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_taskprogresswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/taskprogresswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="250dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="4"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_taskswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/taskswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_todayeventswidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/todayeventswidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="250dp"
android:minResizeWidth="180dp"
android:minResizeHeight="180dp"
android:targetCellWidth="4"
android:targetCellHeight="4"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_todayhubwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/todayhubwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_triagewidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/triagewidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_unreadcountwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/unreadcountwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_upnextwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/upnextwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_vacationwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/vacationwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="250dp"
android:minResizeHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widgetprovider_weekwidget_description"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/weekwidget_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
+11 -2
View File
@@ -1,13 +1,22 @@
import { createElement } from 'react';
import { AppRegistry } from 'react-native';
import { registerRootComponent } from 'expo';
import { registerWidgetTaskHandler } from 'react-native-android-widget';
import { pushBackgroundTask } from './src/lib/push-background-task';
import { widgetTaskHandler } from './src/widgets/task-handler';
import { refreshWidgetsInBackground } from './src/widgets/sync';
// Runs in a fresh headless JS runtime when BulwarkPushTaskService is started
// from BulwarkMessagingService on an FCM data message. Must be registered
// before the native service tries to invoke the task.
AppRegistry.registerHeadlessTask('BulwarkPushTask', () => pushBackgroundTask);
// before the native service tries to invoke the task. New mail also refreshes
// any home-screen widgets (a no-op when none are placed).
AppRegistry.registerHeadlessTask('BulwarkPushTask', () => async (data: Parameters<typeof pushBackgroundTask>[0]) => {
await Promise.allSettled([pushBackgroundTask(data), refreshWidgetsInBackground()]);
});
// Home-screen widgets (Android): every widget event runs this headless task.
registerWidgetTaskHandler(widgetTaskHandler);
// A headless push task evaluates this file too, so App is required when the
// UI first renders instead of imported above: importing it here would load
+186
View File
@@ -249,6 +249,9 @@
"color_tag": "Tag",
"remove_color": "Remove tag"
},
"deep_link": {
"open_failed": "Couldn't open this"
},
"email_composer": {
"attach_camera": "Camera",
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
@@ -1038,5 +1041,188 @@
"severity_deprecated": "Required",
"severity_security": "Security",
"title": "App updates"
},
"widgets": {
"attachments": {
"empty": "No recent attachments",
"from_mail": "from mail",
"title": "Attachments",
"unknown_sender": "Unknown sender"
},
"birthdays": {
"empty": "No birthdays coming up",
"empty_body": "Birthdays come from your contacts.",
"in_days": "{count, plural, one {in # day} other {in # days}}",
"send_wishes": "Send wishes",
"title": "Birthdays",
"wishes_subject": "Happy birthday!"
},
"calendar": {
"agenda": "Agenda",
"agenda_more": "{day}: {events}",
"all_day": "All day",
"join": "Join",
"join_named": "Join {name}",
"more_this_week": "{count, plural, one {# more this week} other {# more this week}}",
"no_more_today": "No more events today",
"nothing_planned": "Nothing on the calendar",
"now": "Now",
"open": "Open",
"running_late": "Running late",
"running_late_body": "I'm running a few minutes late for {title}.",
"running_late_subject": "Running late: {title}",
"today": "Today",
"tomorrow": "Tomorrow",
"unit_days": "{count, plural, one {day} other {days}}",
"unit_hours": "{count, plural, one {hour} other {hours}}",
"unit_minutes": "{count, plural, one {min} other {min}}",
"unsupported": "Calendar isn't available on this server",
"untitled": "(no title)",
"video_call": "Video call"
},
"countdown": {
"days": "{count, plural, one {day} other {days}}",
"empty": "No upcoming events"
},
"draft": {
"edited_at": "Edited {when}",
"edited_minutes": "{count, plural, one {Edited # min ago} other {Edited # min ago}}",
"edited_now": "Edited just now",
"empty": "No drafts",
"empty_body": "Messages you start and do not send wait here.",
"keep_writing": "Keep writing",
"title": "Drafts"
},
"files": {
"empty": "No files yet",
"recent": "Recently changed",
"unsupported": "Files aren't available on this server"
},
"free": {
"busy_until": "Busy until {time}",
"free_rest": "Free for the rest of the day",
"free_until": "Free until {time}",
"mail": "Mail my free times",
"mail_subject": "My free times this week",
"no_calendar": "No calendar on this account",
"then": "then {title}",
"then_free": "then free"
},
"hub": {
"all_clear": "You're all caught up",
"mail_unread": "Mail · {count} unread",
"next": "Next",
"next_on": "Next · {day}",
"no_events": "No upcoming events",
"now": "Now",
"tasks_due": "Tasks · {count} due"
},
"invites": {
"accept": "Accept",
"decline": "Decline",
"empty": "No invitations waiting",
"label": "Invitation · {name}",
"label_plain": "Invitation",
"maybe": "Maybe",
"position": "{index} of {count}"
},
"mail": {
"all_accounts": "All accounts",
"all_caught_up": "All caught up",
"archive": "Archive",
"delete": "Delete",
"empty_all_accounts": "Your inboxes are empty",
"empty_inbox": "Your inbox is empty",
"folders": "Folders",
"in_outbox": "{count, plural, one {# change waiting} other {# changes waiting}}",
"in_outbox_short": "{count} waiting",
"inbox": "Inbox",
"more_unread": "+{count} more unread",
"no_starred": "No starred mail",
"no_starred_body": "Star a message to keep it here.",
"no_subject": "(no subject)",
"no_unread": "No unread messages in your inbox.",
"position": "{index} of {count}",
"reply": "Reply",
"scheduled": "Scheduled",
"starred": "Starred",
"unread": "unread"
},
"people": {
"empty": "No favourites yet",
"empty_body": "The people you hear from most show up here.",
"favourites": "Favourites"
},
"relative": {
"days": "{count, plural, one {in # day} other {in # days}}",
"hours": "in {count} h",
"minutes": "in {count} min",
"now": "now"
},
"scheduled": {
"day_at": "{day}, {time}",
"empty": "Nothing scheduled",
"empty_body": "Messages you schedule to send later show up here.",
"no_recipient": "(no recipient)",
"sending": "Sending now",
"to": "To: {name}",
"today_at": "Today, {time}",
"tomorrow_at": "Tomorrow, {time}",
"waiting": "Waiting for network",
"waiting_body": "Changes you made offline go out when you are back online."
},
"search": {
"no_recent": "Your recent searches show up here.",
"placeholder": "Search mail…",
"recent": "Recent searches"
},
"shortcuts": {
"calendar": "Calendar",
"compose": "Compose",
"contacts": "Contacts",
"files": "Files"
},
"state": {
"error": "Couldn't show this widget",
"loading": "Loading…",
"open_app": "Open the app once if this stays empty.",
"open_to_load": "Open Bulwark to load this",
"signed_out": "Sign in to Bulwark"
},
"storage": {
"no_limit": "No storage limit",
"title": "Storage",
"unavailable": "No quota information",
"used_of": "{used} of {total}"
},
"tag": {
"empty": "No tags yet",
"empty_body": "Tags you add in the app show up here.",
"messages": "{count, plural, one {message} other {messages}}",
"total": "{count, plural, one {# message} other {# messages}}"
},
"tasks": {
"all_done": "All done",
"empty": "No open tasks",
"mark_complete": "Mark as done",
"mark_incomplete": "Mark as not done",
"no_title": "(no title)",
"off": "Tasks are off",
"off_body": "Turn on tasks in the app's calendar settings",
"open_count": "{count} open",
"overdue": "Overdue",
"still_open": "{count} still open",
"title": "Tasks",
"today": "Today",
"tomorrow": "Tomorrow"
},
"vacation": {
"active": "Active",
"inactive": "Inactive",
"subject": "Subject: {subject}",
"title": "Vacation Responder",
"unsupported": "Vacation replies aren't available",
"until": "Until {date}"
}
}
}
+19 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.63",
"version": "0.1.66",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.63",
"version": "0.1.66",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -37,6 +37,7 @@
"postal-mime": "^3.0.0",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-android-widget": "0.22.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-sse": "^1.2.1",
@@ -9004,6 +9005,22 @@
}
}
},
"node_modules/react-native-android-widget": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/react-native-android-widget/-/react-native-android-widget-0.22.1.tgz",
"integrity": "sha512-BBMuqShRrxV23Z09Rn5zNHJaPegCnH3DhhZQgXbhw2GMh45Gez0RbwKTIWHftuW4nLrqWy6esz8XwecUZLiZKA==",
"license": "MIT",
"peerDependencies": {
"expo": ">=54.0.0",
"react": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
}
}
},
"node_modules/react-native-is-edge-to-edge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.63",
"version": "0.1.66",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -14,7 +14,8 @@
"bump": "node scripts/bump-version.mjs",
"sync-locales": "node scripts/sync-locales.mjs",
"i18n:harvest": "node scripts/harvest-rn-keys.mjs",
"i18n:check": "node scripts/harvest-rn-keys.mjs --check"
"i18n:check": "node scripts/harvest-rn-keys.mjs --check",
"widgets:sync": "node scripts/sync-android-widgets.mjs"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -45,6 +46,7 @@
"postal-mime": "^3.0.0",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-android-widget": "0.22.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-sse": "^1.2.1",
+193
View File
@@ -0,0 +1,193 @@
// Generate the Android side of the home-screen widgets from
// src/widgets/catalog.json. Run from the RN repo root:
// node scripts/sync-android-widgets.mjs [--check]
//
// android/ is committed and hand-maintained (no `expo prebuild`), so the
// react-native-android-widget config plugin never runs for it. This script
// does the same job: one RNWidgetProvider subclass per widget (the library
// uses the class name as the widget name), a provider XML per widget, the
// picker labels and descriptions, and the manifest receivers between the
// BEGIN/END WIDGETS markers. With --check it only reports drift and exits 1.
import { readFile, writeFile, readdir, unlink, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const CATALOG = join(ROOT, 'src', 'widgets', 'catalog.json');
const MAIN = join(ROOT, 'android', 'app', 'src', 'main');
const PACKAGE = 'com.anonymous.bulwarkmobile';
const KOTLIN = join(MAIN, 'java', ...PACKAGE.split('.'), 'widget', 'WidgetProviders.kt');
const XML_DIR = join(MAIN, 'res', 'xml');
const STRINGS = join(MAIN, 'res', 'values', 'widget_strings.xml');
// Optional picker previews: src/widgets/previews/<WidgetName>.png is copied to
// res/drawable-nodpi and set as the widget's previewImage.
const PREVIEWS = join(ROOT, 'src', 'widgets', 'previews');
const PREVIEW_DIR = join(MAIN, 'res', 'drawable-nodpi');
const MANIFEST = join(MAIN, 'AndroidManifest.xml');
const CHECK_ONLY = process.argv.includes('--check');
// Android's sizing table: n cells is roughly 70n - 30 dp in either direction.
const dp = (cells) => 70 * cells - 30;
// Launchers refresh at most every 30 minutes; the app pushes fresher data
// itself whenever it has some (see src/widgets/sync.ts).
const UPDATE_PERIOD_MS = 30 * 60 * 1000;
const escapeXml = (s) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, "\\'");
const resName = (name) => `widgetprovider_${name.toLowerCase()}`;
const hasPreview = (w) => existsSync(join(PREVIEWS, `${w.name}.png`));
const previewRes = (w) => `${w.name.toLowerCase()}_preview`;
function providerXml(w) {
const preview = hasPreview(w) ? `\n android:previewImage="@drawable/${previewRes(w)}"` : '';
return `<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="${dp(w.cols)}dp"
android:minHeight="${dp(w.rows)}dp"
android:minResizeWidth="${dp(w.minCols ?? w.cols)}dp"
android:minResizeHeight="${dp(w.minRows ?? w.rows)}dp"
android:targetCellWidth="${w.cols}"
android:targetCellHeight="${w.rows}"
android:resizeMode="horizontal|vertical"
android:description="@string/${resName(w.name)}_description"
android:initialLayout="@layout/rn_widget"${preview}
android:updatePeriodMillis="${w.static ? 0 : UPDATE_PERIOD_MS}"
android:widgetCategory="home_screen">
</appwidget-provider>
`;
}
function kotlinFile(widgets) {
const classes = widgets.map((w) => `class ${w.name} : RNWidgetProvider()`).join('\n');
return `// Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json.
// react-native-android-widget names a widget after its provider class, so each
// widget needs its own subclass; the JS side (src/widgets/task-handler.tsx)
// renders by that name.
package ${PACKAGE}.widget
import com.reactnativeandroidwidget.RNWidgetProvider
${classes}
`;
}
function stringsFile(widgets) {
const lines = widgets.flatMap((w) => [
` <string name="${resName(w.name)}_label">${escapeXml(w.label)}</string>`,
` <string name="${resName(w.name)}_description">${escapeXml(w.description)}</string>`,
]);
return `<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<resources>
${lines.join('\n')}
</resources>
`;
}
function manifestBlock(widgets) {
const receivers = widgets.map(
(w) => ` <receiver android:name=".widget.${w.name}" android:exported="false" android:label="@string/${resName(w.name)}_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${PACKAGE}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/${resName(w.name)}"/>
</receiver>`,
);
return ` <!-- BEGIN WIDGETS: generated by scripts/sync-android-widgets.mjs, do not edit by hand -->
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
${receivers.join('\n')}
<!-- END WIDGETS -->`;
}
function withManifestBlock(manifest, block) {
const begin = manifest.indexOf(' <!-- BEGIN WIDGETS');
const endMarker = '<!-- END WIDGETS -->';
const end = manifest.indexOf(endMarker);
if (begin !== -1 && end !== -1) {
return manifest.slice(0, begin) + block + manifest.slice(end + endMarker.length);
}
const close = manifest.lastIndexOf(' </application>');
if (close === -1) throw new Error('AndroidManifest.xml has no </application>');
return manifest.slice(0, close) + block + '\n' + manifest.slice(close);
}
const { widgets } = JSON.parse(await readFile(CATALOG, 'utf8'));
const names = new Set();
for (const w of widgets) {
if (!/^[A-Z][A-Za-z0-9]*Widget$/.test(w.name)) throw new Error(`Bad widget name ${w.name}`);
if (names.has(w.name)) throw new Error(`Duplicate widget name ${w.name}`);
names.add(w.name);
}
const outputs = new Map();
outputs.set(KOTLIN, kotlinFile(widgets));
outputs.set(STRINGS, stringsFile(widgets));
for (const w of widgets) outputs.set(join(XML_DIR, `${resName(w.name)}.xml`), providerXml(w));
outputs.set(MANIFEST, withManifestBlock(await readFile(MANIFEST, 'utf8'), manifestBlock(widgets)));
// Provider XML for widgets that were removed from the catalog.
const stale = existsSync(XML_DIR)
? (await readdir(XML_DIR))
.filter((f) => f.startsWith('widgetprovider_'))
.map((f) => join(XML_DIR, f))
.filter((f) => !outputs.has(f))
: [];
// Preview images are binary; compare them byte for byte.
const binaryOutputs = new Map();
for (const w of widgets) {
if (hasPreview(w)) binaryOutputs.set(join(PREVIEW_DIR, `${previewRes(w)}.png`), await readFile(join(PREVIEWS, `${w.name}.png`)));
}
const stalePreviews = existsSync(PREVIEW_DIR)
? (await readdir(PREVIEW_DIR))
.filter((f) => /widget_preview\.png$/.test(f))
.map((f) => join(PREVIEW_DIR, f))
.filter((f) => !binaryOutputs.has(f))
: [];
stale.push(...stalePreviews);
let drift = stale.length;
for (const [path, bytes] of binaryOutputs) {
const current = existsSync(path) ? await readFile(path) : null;
if (current && current.equals(bytes)) continue;
drift++;
if (CHECK_ONLY) {
console.log(`out of date: ${path}`);
} else {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, bytes);
console.log(`wrote ${path}`);
}
}
// Checkouts with core.autocrlf have CRLF files: compare without line endings
// and write back in whatever the file already uses.
const lf = (text) => text.replace(/\r\n/g, '\n');
for (const [path, content] of outputs) {
const current = existsSync(path) ? await readFile(path, 'utf8') : null;
if (current !== null && lf(current) === lf(content)) continue;
drift++;
if (CHECK_ONLY) {
console.log(`out of date: ${path}`);
} else {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, current?.includes('\r\n') ? lf(content).replace(/\n/g, '\r\n') : lf(content));
console.log(`wrote ${path}`);
}
}
for (const path of stale) {
if (CHECK_ONLY) console.log(`stale: ${path}`);
else {
await unlink(path);
console.log(`removed ${path}`);
}
}
if (CHECK_ONLY && drift > 0) {
console.error(`${drift} widget file(s) out of date; run node scripts/sync-android-widgets.mjs`);
process.exit(1);
}
console.log(drift === 0 ? 'Android widget files are up to date.' : `${widgets.length} widgets synced.`);
+50
View File
@@ -0,0 +1,50 @@
import type { Email } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
import { pickEmailBody, plainTextBody } from './email-body';
/**
* Composer params for a reply / reply-all / forward of `source`. Shared by
* the thread viewer and the `mail/message/<id>?action=reply` deep link (the
* triage widget). Null when there is nobody to reply to.
*/
export function replyComposeParams(
mode: 'reply' | 'replyAll' | 'forward',
source: Email,
ownerAccountId?: string,
): NonNullable<RootStackParamList['Compose']> | null {
const from = source.from?.[0];
if (!from && mode !== 'forward') return null;
// Quote the HTML part when there is one so layout and inline images
// survive (#163). RFC 8621 §4.1.4: an HTML-only message exposes the same
// part in `textBody` and `htmlBody`, so the text part is only a real
// alternative when its partId differs - otherwise the composer would be
// handed raw HTML source (#649, native #46).
const picked = pickEmailBody(source);
const quoteHtml = picked.html ?? undefined;
const body = !quoteHtml || picked.text ? plainTextBody(source) : undefined;
return {
mode,
replyTo: {
from: from ?? { email: '' },
to: source.to,
cc: source.cc,
// RFC 5322: a reply goes to Reply-To when the sender set one.
replyToAddresses: source.replyTo,
subject: source.subject ?? '',
body,
htmlBody: quoteHtml,
receivedAt: source.receivedAt,
sentAt: source.sentAt,
// Threading needs the RFC Message-ID, never the JMAP object id (#234).
messageId: source.messageId ?? undefined,
references: source.references ?? undefined,
// Forward carries the original attachments as blob refs; cid-embedded
// inline images are already part of the quoted HTML.
attachments: mode === 'forward'
? (source.attachments ?? []).filter((a) => !(a.disposition === 'inline' && a.cid))
: undefined,
originalEmailId: source.id,
jmapAccountId: ownerAccountId,
},
};
}
+67
View File
@@ -2,6 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
import { handleDeepLink, parseDeepLink, shareToDeepLink } from '../linking';
import { usePendingSettingsTab } from '../pending-settings-tab';
import { usePendingCalendarOpen } from '../pending-calendar-open';
import { usePendingMailSearch } from '../pending-mail-search';
import { links } from '../../widgets/clicks';
describe('parseDeepLink', () => {
it('parses app-scheme mail links', () => {
@@ -75,6 +77,26 @@ describe('parseDeepLink', () => {
});
});
it('parses the widget links: reply, draft, unified views, scheduled and search', () => {
expect(parseDeepLink('bulwarkmobile://mail/message/M1?account=a%40b&jmapAccount=team&action=reply')).toEqual({
kind: 'message', emailId: 'M1', accountId: 'a@b', jmapAccountId: 'team', action: 'reply',
});
// What the widgets build is what the app reads, thread included.
const m = { id: 'M/1', threadId: 'T1', accountId: 'a@b', jmapAccountId: 'team' };
expect(parseDeepLink(links.reply(m))).toEqual({
kind: 'message', emailId: 'M/1', accountId: 'a@b', jmapAccountId: 'team', threadId: 'T1', action: 'reply',
});
expect(parseDeepLink(links.message({ id: 'M2', accountId: 'a@b' }))).toEqual({
kind: 'message', emailId: 'M2', accountId: 'a@b',
});
expect(parseDeepLink('bulwarkmobile://mail/draft/D1')).toEqual({ kind: 'draft', emailId: 'D1', accountId: undefined });
expect(parseDeepLink('bulwarkmobile://mail/unified?view=starred')).toEqual({ kind: 'unified', view: 'starred' });
expect(parseDeepLink('bulwarkmobile://mail/unified?role=drafts&view=bogus')).toEqual({ kind: 'unified', role: 'drafts' });
expect(parseDeepLink('bulwarkmobile://mail/scheduled')).toEqual({ kind: 'scheduled' });
expect(parseDeepLink('bulwarkmobile://mail/search?q=from%3Aada')).toEqual({ kind: 'search', query: 'from:ada' });
expect(parseDeepLink('bulwarkmobile://mail/search')).toEqual({ kind: 'search', query: '' });
});
it('rejects unknown links', () => {
expect(parseDeepLink('bulwarkmobile://whatever')).toBeNull();
expect(parseDeepLink('garbage')).toBeNull();
@@ -166,6 +188,51 @@ describe('handleDeepLink', () => {
}));
});
it('opens a group-mailbox message in its JMAP account', async () => {
const navigation = nav();
const resolveThreadId = vi.fn(async () => 'T1');
await handleDeepLink(
{ kind: 'message', emailId: 'M1', jmapAccountId: 'team' },
{ navigation: navigation as never, resolveThreadId },
);
expect(resolveThreadId).toHaveBeenCalledWith('M1', 'team');
expect(navigation.navigate).toHaveBeenCalledWith('EmailThread', { emailId: 'M1', threadId: 'T1', jmapAccountId: 'team' });
});
it('opens a reply over its message, without a lookup when the link names the thread', async () => {
// Offline, or before the session is back: the lookup would fail.
const resolveThreadId = vi.fn(async () => null);
const navigation = nav();
const ok = await handleDeepLink(
{ kind: 'message', emailId: 'M2', threadId: 'T2', action: 'reply' },
{ navigation: navigation as never, resolveThreadId },
);
expect(ok).toBe(true);
expect(resolveThreadId).not.toHaveBeenCalled();
expect(navigation.navigate).toHaveBeenCalledWith('EmailThread', { emailId: 'M2', threadId: 'T2', action: 'reply' });
// Without the thread and without a server, nothing opens (the app says so).
expect(await handleDeepLink(
{ kind: 'message', emailId: 'M3', action: 'reply' },
{ navigation: nav() as never, resolveThreadId },
)).toBe(false);
});
it('opens drafts, unified views, scheduled mail and searches', async () => {
const openDraft = vi.fn(async () => true);
const navigation = nav();
const deps = { navigation: navigation as never, resolveThreadId: async () => 'T', openDraft };
expect(await handleDeepLink({ kind: 'draft', emailId: 'D1' }, deps)).toBe(true);
expect(openDraft).toHaveBeenCalledWith('D1', undefined);
await handleDeepLink({ kind: 'unified', view: 'starred' }, deps);
expect(navigation.navigate).toHaveBeenCalledWith('UnifiedInbox', { view: 'starred' });
await handleDeepLink({ kind: 'scheduled' }, deps);
expect(navigation.navigate).toHaveBeenCalledWith('Scheduled');
await handleDeepLink({ kind: 'search', query: 'from:ada' }, deps);
expect(usePendingMailSearch.getState().consume()).toBe('from:ada');
expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Mail' });
});
it('refuses when the linked account is not signed in', async () => {
const navigation = nav();
const ok = await handleDeepLink(
+71 -6
View File
@@ -8,13 +8,26 @@ import { parseMailtoUrl } from '../lib/mailto';
import type { RootStackParamList } from './types';
import { setPendingSettingsTab } from './pending-settings-tab';
import { setPendingCalendarOpen } from './pending-calendar-open';
import { setPendingMailSearch } from './pending-mail-search';
export const APP_SCHEME = 'bulwarkmobile';
const UNIFIED_ROLES = ['inbox', 'sent', 'drafts', 'junk', 'archive', 'trash'] as const;
const UNIFIED_VIEWS = ['all', 'unread', 'starred'] as const;
type UnifiedRole = typeof UNIFIED_ROLES[number];
type UnifiedView = typeof UNIFIED_VIEWS[number];
export type DeepLink =
| { kind: 'message'; emailId: string; accountId?: string }
// `jmapAccountId`: the JMAP account owning a message in a group/shared
// mailbox (`?jmapAccount=`); `threadId` (`?thread=`) saves looking it up;
// `action: 'reply'` opens a reply to it.
| { kind: 'message'; emailId: string; accountId?: string; jmapAccountId?: string; threadId?: string; action?: 'reply' }
| { kind: 'draft'; emailId: string; accountId?: string; jmapAccountId?: string }
| { kind: 'thread'; threadId: string; accountId?: string }
| { kind: 'folder'; ref: string; accountId?: string }
| { kind: 'unified'; role?: UnifiedRole; view?: UnifiedView }
| { kind: 'scheduled' }
| { kind: 'search'; query: string }
// `jmapAccountId`: the JMAP account owning the event (`?account=`), for
// one on a calendar shared with the user.
| { kind: 'calendar'; eventId?: string; date?: string; jmapAccountId?: string }
@@ -82,7 +95,32 @@ export function parseDeepLink(url: string): DeepLink | null {
switch (area) {
case 'mail': {
if (kind === 'message' && value) return { kind: 'message', emailId: decodeSegment(value), accountId };
const jmapAccountId = search.get('jmapAccount') ?? undefined;
if (kind === 'message' && value) {
const threadId = search.get('thread');
return {
kind: 'message',
emailId: decodeSegment(value),
accountId,
...(jmapAccountId ? { jmapAccountId } : {}),
...(threadId ? { threadId } : {}),
...(search.get('action') === 'reply' ? { action: 'reply' as const } : {}),
};
}
if (kind === 'draft' && value) {
return { kind: 'draft', emailId: decodeSegment(value), accountId, ...(jmapAccountId ? { jmapAccountId } : {}) };
}
if (kind === 'unified') {
const role = search.get('role');
const view = search.get('view');
return {
kind: 'unified',
...(role && (UNIFIED_ROLES as readonly string[]).includes(role) ? { role: role as UnifiedRole } : {}),
...(view && (UNIFIED_VIEWS as readonly string[]).includes(view) ? { view: view as UnifiedView } : {}),
};
}
if (kind === 'scheduled') return { kind: 'scheduled' };
if (kind === 'search') return { kind: 'search', query: search.get('q') ?? '' };
if (kind === 'thread' && value) return { kind: 'thread', threadId: decodeSegment(value), accountId };
if (kind === 'folder' && value) return { kind: 'folder', ref: decodeSegment(value), accountId };
// Legacy `?email=<id>` from the webmail's older service worker.
@@ -131,8 +169,12 @@ export function parseDeepLink(url: string): DeepLink | null {
export interface DeepLinkNavigator {
navigation: NavigationContainerRefWithCurrent<RootStackParamList>;
// Resolve a message id to its thread (EmailThread needs both). Returns
// null when the message cannot be loaded.
resolveThreadId: (emailId: string) => Promise<string | null>;
// null when the message cannot be loaded. `jmapAccountId` names the
// group/shared account holding it, when it is not the user's own.
resolveThreadId: (emailId: string, jmapAccountId?: string) => Promise<string | null>;
// Open the composer on a server draft. Resolve false when the draft cannot
// be loaded.
openDraft?: (emailId: string, jmapAccountId?: string) => Promise<boolean>;
// Switch to the account a permalink names (`?account=`); resolves false
// when that account is not signed in on this device.
switchAccount?: (accountId: string) => Promise<boolean>;
@@ -149,11 +191,34 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr
switch (link.kind) {
case 'message': {
const threadId = await nav.resolveThreadId(link.emailId);
const threadId = link.threadId ?? await nav.resolveThreadId(link.emailId, link.jmapAccountId);
if (!threadId) return false;
navigation.navigate('EmailThread', { emailId: link.emailId, threadId });
// A reply opens over the message, like the reader's own Reply: the
// reader loads it (from its cache when offline) and then opens the
// composer, or shows why it could not.
navigation.navigate('EmailThread', {
emailId: link.emailId,
threadId,
...(link.jmapAccountId ? { jmapAccountId: link.jmapAccountId } : {}),
...(link.action === 'reply' ? { action: 'reply' as const } : {}),
});
return true;
}
case 'draft':
return nav.openDraft ? nav.openDraft(link.emailId, link.jmapAccountId) : false;
case 'unified':
navigation.navigate('UnifiedInbox', {
...(link.role ? { role: link.role } : {}),
...(link.view ? { view: link.view } : {}),
});
return true;
case 'scheduled':
navigation.navigate('Scheduled');
return true;
case 'search':
setPendingMailSearch(link.query);
navigation.navigate('MainTabs', { screen: 'Mail' } as never);
return true;
case 'thread':
// The reader keys on the message; without one, open the list.
navigation.navigate('MainTabs', { screen: 'Mail' } as never);
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand';
// A `mail/search` deep link (the search widget) arrives before the mail list
// is mounted, or while it shows a folder. The query is parked here and
// consumed by EmailListScreen, which runs it like a submitted search; an
// empty query just opens the search field.
interface PendingMailSearchState {
query: string | null;
set: (query: string | null) => void;
consume: () => string | null;
}
export const usePendingMailSearch = create<PendingMailSearchState>((set, get) => ({
query: null,
set: (query) => set({ query }),
consume: () => {
const query = get().query;
if (query !== null) set({ query: null });
return query;
},
}));
export function setPendingMailSearch(query: string | null): void {
usePendingMailSearch.getState().set(query);
}
+2
View File
@@ -67,6 +67,8 @@ export type RootStackParamList = {
* viewer pages over the store's current folder.
*/
emailIds?: string[];
/** Open the composer on a reply once the message has loaded (widget Reply). */
action?: 'reply';
};
EmailSource: { emailId: string; blobId: string; subject?: string; jmapAccountId?: string };
Compose:
+13
View File
@@ -49,6 +49,7 @@ import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm
import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context';
import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
import type { RootStackParamList } from '../navigation/types';
import { usePendingMailSearch } from '../navigation/pending-mail-search';
import type { Attachment, Email } from '../api/types';
function getSenderName(email: Email, unknownLabel: string): string {
@@ -824,6 +825,17 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
if (value.trim()) addRecentSearch(value);
setSearchFocused(false);
}, [setSearchQuery, addRecentSearch]);
// A search handed over by a deep link (the search widget): run it, or with
// no query just put the cursor in the search field.
const searchInputRef = React.useRef<TextInput>(null);
const pendingSearch = usePendingMailSearch((s) => s.query);
React.useEffect(() => {
if (pendingSearch === null) return;
const query = usePendingMailSearch.getState().consume();
if (query === null) return;
if (query.trim()) submitSearch(query);
else setTimeout(() => searchInputRef.current?.focus(), 300);
}, [pendingSearch, submitSearch]);
// People whose name/address matches what is being typed (#845); picking
// one turns the search into a `from:` filter like the webmail.
const contactSuggestions = React.useMemo(() => {
@@ -1120,6 +1132,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<View style={styles.searchInputArea}>
<Search size={16} color={c.textMuted} />
<TextInput
ref={searchInputRef}
style={styles.searchInput}
placeholder={t('email_list.search_placeholder', 'Search mail...')}
placeholderTextColor={c.textMuted}
+12 -36
View File
@@ -38,7 +38,7 @@ import { useLocaleStore } from '../stores/locale-store';
import {
findArchiveMailbox, findTrashMailbox, mailboxAccountId, mailboxesOfAccount, mailboxOfEmail,
} from '../lib/mailbox-tree';
import { pickEmailBody, plainTextBody } from '../lib/email-body';
import { replyComposeParams } from '../lib/reply-compose';
import { singleLine } from '../lib/single-line';
import { buildForwardAsAttachmentPayload } from '../lib/forward-as-attachment';
import { viewerInstance, viewerPages, type ViewerInstance } from '../lib/viewer-pages';
@@ -532,43 +532,19 @@ function EmailViewer({ route, navigation }: Props) {
const navigateCompose = React.useCallback((mode: 'reply' | 'replyAll' | 'forward', target?: Email) => {
const source = target ?? email;
if (!source) return;
const from = source.from?.[0];
if (!from && mode !== 'forward') return;
// Quote the HTML part when there is one so layout and inline images
// survive (#163). RFC 8621 §4.1.4: an HTML-only message exposes the same
// part in `textBody` and `htmlBody`, so the text part is only a real
// alternative when its partId differs - otherwise the composer would be
// handed raw HTML source (#649, native #46).
const picked = pickEmailBody(source);
const quoteHtml = picked.html ?? undefined;
const body = !quoteHtml || picked.text ? plainTextBody(source) : undefined;
navigation.navigate('Compose', {
mode,
replyTo: {
from: from ?? { email: '' },
to: source.to,
cc: source.cc,
// RFC 5322: a reply goes to Reply-To when the sender set one.
replyToAddresses: source.replyTo,
subject: source.subject ?? '',
body,
htmlBody: quoteHtml,
receivedAt: source.receivedAt,
sentAt: source.sentAt,
// Threading needs the RFC Message-ID, never the JMAP object id (#234).
messageId: source.messageId ?? undefined,
references: source.references ?? undefined,
// Forward carries the original attachments as blob refs; cid-embedded
// inline images are already part of the quoted HTML.
attachments: mode === 'forward'
? (source.attachments ?? []).filter((a) => !(a.disposition === 'inline' && a.cid))
: undefined,
originalEmailId: source.id,
jmapAccountId: ownerAccountId,
},
});
const params = replyComposeParams(mode, source, ownerAccountId);
if (params) navigation.navigate('Compose', params);
}, [email, navigation, ownerAccountId]);
// Opened to reply (a widget's Reply): the composer follows as soon as the
// message is here, once. If it never loads, the reader's error says why.
const replyPending = React.useRef(route.params.action === 'reply');
React.useEffect(() => {
if (!replyPending.current || email?.id !== route.params.emailId) return;
replyPending.current = false;
navigateCompose('reply', email);
}, [email, route.params.emailId, navigateCompose]);
// Forward the raw message as a message/rfc822 attachment (webmail 1.8.1).
const onForwardAsAttachment = () => {
setMoreMenuOpen(false);
+4 -1
View File
@@ -552,8 +552,11 @@ export function spansAccounts(state: Pick<EmailState, 'searchQuery' | 'filters'>
*/
export function spannedAccounts(mailboxes: Mailbox[]): Array<string | undefined> {
const out: Array<string | undefined> = [undefined];
// The drawer renders the persisted mailboxes before the session is back,
// and `accountId` throws while the client is not connected.
const own = jmapClient.isConnected ? jmapClient.accountId : undefined;
for (const m of mailboxes) {
if (m.isShared && m.accountId && m.accountId !== jmapClient.accountId && !out.includes(m.accountId)) {
if (m.isShared && m.accountId && m.accountId !== own && !out.includes(m.accountId)) {
out.push(m.accountId);
}
}
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest';
import {
agendaDays,
busyBlocks,
countdownTarget,
dueKind,
eventsOnDay,
freeTime,
monthGrid,
nextEvent,
sortTasks,
startOfWeek,
} from '../derive';
import type { EventItem, TaskItem } from '../snapshot';
// Monday 28 September 2026, 09:05 local time.
const NOW = new Date(2026, 8, 28, 9, 5).getTime();
const at = (day: number, h: number, m = 0) => new Date(2026, 8, day, h, m).getTime();
function ev(id: string, start: number, end: number, extra: Partial<EventItem> = {}): EventItem {
return {
id,
serverId: id,
title: id,
start,
end,
allDay: false,
color: '#22c55e',
calendarName: 'Work',
participants: [],
...extra,
};
}
const standup = ev('standup', at(28, 9, 30), at(28, 10));
const dentist = ev('dentist', at(29, 10), at(29, 11), { color: '#3b82f6' });
const lunch = ev('lunch', at(30, 12, 30), at(30, 13, 30));
const party = ev('party', new Date(2026, 9, 1).getTime(), new Date(2026, 9, 2).getTime(), { allDay: true, color: '#3b82f6' });
const review = ev('review', new Date(2026, 9, 1, 14).getTime(), new Date(2026, 9, 1, 16).getTime());
const trip = ev('trip', new Date(2026, 9, 4).getTime(), new Date(2026, 9, 5).getTime(), { allDay: true, color: '#3b82f6' });
const earlier = ev('earlier', at(28, 7), at(28, 8));
const EVENTS = [earlier, standup, dentist, lunch, party, review, trip];
describe('calendar views', () => {
it('finds the next timed event and whether it is running', () => {
expect(nextEvent(EVENTS, NOW)?.event.id).toBe('standup');
expect(nextEvent(EVENTS, NOW)?.running).toBe(false);
expect(nextEvent(EVENTS, at(28, 9, 45))?.running).toBe(true);
});
it('lists a day with all-day events first', () => {
const thursday = new Date(2026, 9, 1).getTime();
expect(eventsOnDay(EVENTS, thursday).map((e) => e.id)).toEqual(['party', 'review']);
});
it('drops finished events from today in the agenda', () => {
const days = agendaDays(EVENTS, NOW, 3);
expect(days.map((d) => d.events.map((e) => e.id))).toEqual([['standup'], ['dentist'], ['lunch']]);
});
it('starts the week on the configured day', () => {
expect(new Date(startOfWeek(NOW, 1)).getDate()).toBe(28);
expect(new Date(startOfWeek(NOW, 0)).getDate()).toBe(27);
});
it('builds the month as whole weeks with dots and today', () => {
const weeks = monthGrid(EVENTS, NOW, 1);
expect(weeks).toHaveLength(5);
expect(weeks[0][0].day).toBe(31);
expect(weeks[0][0].inMonth).toBe(false);
const today = weeks.flat().find((c) => c.isToday);
expect(today?.day).toBe(28);
expect(today?.dots).toEqual(['#22c55e']);
});
it('places busy blocks inside working hours', () => {
const [block] = busyBlocks(EVENTS, new Date(2026, 8, 29).getTime());
expect(block.from).toBeCloseTo(0.2);
expect(block.to).toBeCloseTo(0.3);
});
it('works out free time and the longest gap left today', () => {
const free = freeTime(EVENTS, NOW);
expect(free.busyNow).toBe(false);
expect(free.next?.id).toBe('standup');
expect(free.longestGap).toEqual({ from: at(28, 10), to: at(28, 18) });
});
it('counts down to the next all-day or multi-day event', () => {
expect(countdownTarget(EVENTS, NOW)?.id).toBe('party');
});
});
describe('tasks', () => {
const task = (id: string, due: number | undefined, done = false, dueHasTime = false): TaskItem => ({
id, serverId: id, title: id, due, dueHasTime, done, calendarName: 'Personal', color: '#3b82f6',
});
it('classifies due dates', () => {
expect(dueKind(task('a', at(26, 0)), NOW)).toBe('overdue');
expect(dueKind(task('b', at(28, 0)), NOW)).toBe('today');
expect(dueKind(task('c', at(28, 8), false, true), NOW)).toBe('overdue');
expect(dueKind(task('d', at(29, 0)), NOW)).toBe('tomorrow');
expect(dueKind(task('e', undefined), NOW)).toBe('none');
});
it('puts overdue first and done last', () => {
const sorted = sortTasks([
task('done', at(20, 0), true),
task('later', new Date(2026, 9, 3).getTime()),
task('none', undefined),
task('today', at(28, 0)),
task('overdue', at(26, 0)),
], NOW);
expect(sorted.map((t) => t.id)).toEqual(['overdue', 'today', 'later', 'none', 'done']);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import type { Email, Mailbox } from '../../api/types';
import { folderCounts, rankPeople, toMailItem } from '../jmap';
function email(id: string, from: string, name: string, seen: boolean, receivedAt = '2026-09-28T08:00:00Z'): Email {
return {
id,
threadId: `t-${id}`,
mailboxIds: { inbox: true },
keywords: seen ? { $seen: true } : {},
receivedAt,
from: [{ email: from, name }],
subject: `Subject ${id}`,
preview: ' line one\n line two ',
hasAttachment: false,
size: 1,
} as Email;
}
describe('toMailItem', () => {
it('maps a list email to what the widgets draw', () => {
const item = toMailItem(email('1', 'sofia.russo@example.com', 'Sofia Russo', false), 'acc', { threadSize: 2 });
expect(item).toMatchObject({
id: '1',
accountId: 'acc',
fromName: 'Sofia Russo',
initials: 'SR',
unread: true,
threadSize: 2,
preview: 'line one line two',
});
// Same hue as the webmail's Avatar for this name.
expect(item.color).toBe('#6826d9');
});
});
describe('rankPeople', () => {
it('ranks senders by volume, skips the user and no-reply senders, and counts unread', () => {
const people = rankPeople([
email('1', 'ada@example.com', 'Ada', false),
email('2', 'ada@example.com', 'Ada', true),
email('3', 'bob@example.com', 'Bob', false),
email('4', 'me@example.com', 'Me', false),
email('5', 'noreply@shop.example', 'Shop', false),
], ['Me@example.com']);
expect(people.map((p) => [p.email, p.unread])).toEqual([['ada@example.com', 1], ['bob@example.com', 1]]);
});
});
describe('folderCounts', () => {
it('orders the roles like the sidebar and finds junk by role or name', () => {
const boxes = [
{ id: 'j', name: 'Spam', role: 'junk', unreadEmails: 3, totalEmails: 3 },
{ id: 'i', name: 'Inbox', role: 'inbox', unreadEmails: 10, totalEmails: 23 },
{ id: 'd', name: 'Drafts', role: 'drafts', unreadEmails: 0, totalEmails: 2 },
] as Mailbox[];
expect(folderCounts(boxes).map((f) => [f.role, f.unread, f.total])).toEqual([
['inbox', 10, 23],
['drafts', 0, 2],
['junk', 3, 3],
]);
});
});
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from 'vitest';
// The package entry pulls in the native module, so hand the layouts the plain
// widget components and run them through the library's own tree builder: it
// is what throws on the device when a layout returns null, puts null in a
// mapped list or uses a Fragment. Colours are checked on the way in, because
// the library quietly turns any it cannot parse (a name, `transparent`) into
// white.
vi.mock('react-native-android-widget', async () => {
const COLOR = /^(#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|rgba\(.*\))$/i;
const checkColors = (widget: string, style: Record<string, unknown> = {}) => {
for (const [key, value] of Object.entries(style)) {
if (/colou?r$/i.test(key) && !(typeof value === 'string' && COLOR.test(value))) {
throw new Error(`${widget} ${key}: ${JSON.stringify(value)} is not a colour the widget renderer reads`);
}
}
};
type Widget = { __name__: string; convertProps: (props: { style?: Record<string, unknown> }) => unknown };
const checked = (module: Record<string, unknown>) => {
for (const value of Object.values(module)) {
if (typeof value !== 'function' || !('__name__' in value)) continue;
const widget = value as unknown as Widget;
const convert = widget.convertProps;
widget.convertProps = (props) => {
checkColors(widget.__name__, props.style);
return convert(props);
};
}
return module;
};
// The compiled SvgWidget requires react-native (past the stub) to resolve
// bundled images; the widgets only draw inline SVG strings, so rebuild it
// from the library's own style and click converters.
const { convertCommonStyle } = await import('react-native-android-widget/lib/commonjs/widgets/utils/style.utils' as string);
const { convertClickAction } = await import('react-native-android-widget/lib/commonjs/widgets/utils/click-action' as string);
function SvgWidget() {
return null;
}
SvgWidget.__name__ = 'SvgWidget';
SvgWidget.convertProps = (props: { style?: object; svg: unknown }) => {
if (typeof props.svg !== 'string') throw new Error('widgets draw inline SVG strings only');
return { ...convertCommonStyle(props.style ?? {}), ...convertClickAction(props), svgString: props.svg };
};
return {
...checked(await import('react-native-android-widget/lib/commonjs/widgets/FlexWidget' as string)),
...checked(await import('react-native-android-widget/lib/commonjs/widgets/ListWidget' as string)),
...checked(await import('react-native-android-widget/lib/commonjs/widgets/OverlapWidget' as string)),
...checked(await import('react-native-android-widget/lib/commonjs/widgets/TextWidget' as string)),
...checked({ SvgWidget }),
getWidgetInfo: vi.fn(async () => []),
requestWidgetUpdate: vi.fn(async () => undefined),
};
});
import { createElement } from 'react';
import { FlexWidget } from 'react-native-android-widget';
import catalog from '../catalog.json';
import { renderFor } from '../render';
import { PREVIEW_NOW, sampleSnapshot } from '../sample-snapshot';
import { emptySnapshot, type WidgetSnapshot } from '../snapshot';
const { buildWidgetTree } = (await import(
'react-native-android-widget/lib/commonjs/api/build-widget-tree' as string
)) as { buildWidgetTree: (tree: unknown) => { type: string } };
// Launcher cells to dp, roughly what a phone launcher reports.
const dp = (cells: number) => cells * 90 - 15;
function build(name: string, s: WidgetSnapshot, cols: number, rows: number, local = {}) {
const trees = renderFor(name, s, { width: dp(cols), height: dp(rows), widgetId: 1 }, local, PREVIEW_NOW);
for (const tree of 'light' in trees ? [trees.light, trees.dark] : [trees]) {
expect(buildWidgetTree(tree).type).toBeTruthy();
}
}
const sizes = (w: (typeof catalog.widgets)[number]) => [
[w.minCols, w.minRows],
[w.cols, w.rows],
[4, 4],
[5, 5],
];
describe('widget layouts', () => {
it('uses a builder that rejects what the device rejects', () => {
const Nothing = () => null;
expect(() => buildWidgetTree(createElement(Nothing))).toThrow();
expect(() => buildWidgetTree(createElement(FlexWidget, null, createElement(FlexWidget), [null]))).toThrow();
expect(() => buildWidgetTree(createElement(FlexWidget, { style: { backgroundColor: 'teal' } } as never))).toThrow();
});
const sample = { ...sampleSnapshot(PREVIEW_NOW), theme: 'system' as const };
const empty: WidgetSnapshot = {
...emptySnapshot(),
generatedAt: PREVIEW_NOW,
appDataAt: PREVIEW_NOW,
signedIn: true,
theme: 'system',
accounts: [{ id: 'a', label: 'Work', color: '#2563eb', unread: 0 }],
activeAccountId: 'a',
calendar: { supported: true, events: [], invitations: [], birthdays: [] },
tasks: { supported: true, items: [] },
files: { supported: true, items: [] },
};
it.each(catalog.widgets.map((w) => [w.name, w] as const))('%s builds with demo data at every size', (name, w) => {
for (const [cols, rows] of sizes(w)) build(name, sample, cols, rows);
});
it.each(catalog.widgets.map((w) => [w.name, w] as const))('%s builds with nothing to show', (name, w) => {
for (const [cols, rows] of sizes(w)) build(name, empty, cols, rows);
});
it('builds the signed-out, loading and not-loaded-yet states', () => {
for (const w of catalog.widgets) {
build(w.name, { ...emptySnapshot(), generatedAt: PREVIEW_NOW }, w.cols, w.rows);
build(w.name, emptySnapshot(), w.cols, w.rows);
build(w.name, { ...sample, appDataAt: 0 }, w.cols, w.rows);
}
});
it('builds the triage card on a message that is gone', () => {
build('TriageWidget', sample, 4, 2, { triageId: 'no-such-message' });
build('TriageWidget', { ...sample, mail: { ...sample.mail, inbox: [] } }, 4, 2, { triageId: 'm1' });
});
});
+163
View File
@@ -0,0 +1,163 @@
// Buttons that act without opening the app: archive / delete / next on the
// triage card, RSVP on the invitation card, ticking a task. Each one updates
// the stored snapshot first so every widget redraws at once, then makes the
// JMAP call, then refreshes from the server; a failed call is undone by that
// refresh.
import { jmapClient } from '../api/jmap-client';
import type { JMAPClient } from '../api/jmap-client';
import { noteLocalChange, refreshSnapshot, singletonServes } from './build';
import { markRead, moveTo, openClient, rsvp, setTaskDone } from './jmap';
import { loadLocal, saveLocal } from './local-state';
import { updateAllWidgets } from './render';
import { emptySnapshot, loadSnapshot, saveSnapshot, type MailItem, type WidgetSnapshot } from './snapshot';
async function clientFor(registryAccountId: string | null | undefined): Promise<JMAPClient | null> {
if (!registryAccountId) return null;
if (singletonServes(registryAccountId)) return jmapClient;
return openClient(registryAccountId);
}
async function applyLocally(mutate: (s: WidgetSnapshot) => void): Promise<WidgetSnapshot> {
noteLocalChange();
const s = (await loadSnapshot()) ?? emptySnapshot();
mutate(s);
await saveSnapshot(s);
await updateAllWidgets(s);
return s;
}
function withoutMessage(s: WidgetSnapshot, id: string): void {
const removed = s.mail.inbox.find((m) => m.id === id);
const drop = (list: MailItem[]) => list.filter((m) => m.id !== id);
s.mail.inbox = drop(s.mail.inbox);
s.mail.unified = drop(s.mail.unified);
s.mail.starred = drop(s.mail.starred);
const inbox = s.mail.folders.find((f) => f.role === 'inbox');
if (inbox && removed) {
inbox.total = Math.max(0, inbox.total - 1);
if (removed.unread) inbox.unread = Math.max(0, inbox.unread - 1);
}
}
async function afterServerCall(ok: boolean): Promise<void> {
if (!ok) console.warn('[widgets] action was not applied on the server');
const fresh = await refreshSnapshot({ after: 'change' });
await updateAllWidgets(fresh);
}
type Data = Record<string, unknown>;
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
export async function handleWidgetAction(name: string, data: Data, widgetId: number): Promise<void> {
switch (name) {
case 'refresh':
await afterServerCall(true);
return;
case 'triageNext': {
const s = (await loadSnapshot()) ?? emptySnapshot();
const unread = s.mail.inbox.filter((m) => m.unread);
if (unread.length === 0) return;
const index = unread.findIndex((m) => m.id === data.id);
const next = unread[(index + 1) % unread.length];
const local = await loadLocal(widgetId);
await saveLocal(widgetId, { ...local, triageId: next.id });
await updateAllWidgets(s);
return;
}
case 'archive':
case 'trash':
case 'markRead': {
const id = str(data.id);
if (!id) return;
if (name === 'markRead') {
await applyLocally((s) => {
const hit = s.mail.inbox.find((m) => m.id === id);
const inbox = s.mail.folders.find((f) => f.role === 'inbox');
if (hit?.unread && inbox) inbox.unread = Math.max(0, inbox.unread - 1);
for (const list of [s.mail.inbox, s.mail.unified, s.mail.starred]) {
for (const m of list) if (m.id === id) m.unread = false;
}
});
} else {
// The triage card moves on to the next unread message, not back to
// the first one.
const before = (await loadSnapshot()) ?? emptySnapshot();
const unread = before.mail.inbox.filter((m) => m.unread);
const index = unread.findIndex((m) => m.id === id);
const next = index >= 0 ? unread[index + 1] ?? unread[index - 1] : undefined;
if (typeof data.widgetId === 'number') {
await saveLocal(data.widgetId, { ...(await loadLocal(data.widgetId)), triageId: next?.id ?? null });
}
await applyLocally((s) => withoutMessage(s, id));
}
let ok = false;
try {
const client = await clientFor(str(data.accountId));
if (client) {
ok = name === 'markRead'
? await markRead(client, id, str(data.jmapAccountId))
: await moveTo(client, id, name, str(data.jmapAccountId));
}
} catch (err) {
console.warn(`[widgets] ${name} failed`, err);
}
await afterServerCall(ok);
return;
}
case 'rsvp': {
const id = str(data.id);
const status = data.status;
if (!id || (status !== 'accepted' && status !== 'tentative' && status !== 'declined')) return;
const before = (await loadSnapshot()) ?? emptySnapshot();
const invitation = before.calendar.invitations.find((i) => i.id === id);
if (!invitation) return;
await applyLocally((s) => {
s.calendar.invitations = s.calendar.invitations.filter((i) => i.id !== id);
for (const e of s.calendar.events) if (e.serverId === invitation.serverId) e.myStatus = status;
});
let ok = false;
try {
const client = await clientFor(before.activeAccountId);
if (client) ok = await rsvp(client, invitation.serverId, invitation.participantId, status, invitation.jmapAccountId);
} catch (err) {
console.warn('[widgets] rsvp failed', err);
}
await afterServerCall(ok);
return;
}
case 'toggleTask': {
const id = str(data.id);
if (!id) return;
const before = (await loadSnapshot()) ?? emptySnapshot();
const task = before.tasks.items.find((t) => t.id === id);
if (!task) return;
const done = !task.done;
await applyLocally((s) => {
for (const t of s.tasks.items) if (t.id === id) t.done = done;
});
let ok = false;
try {
const client = await clientFor(before.activeAccountId);
if (client) ok = await setTaskDone(client, task.serverId, done, task.jmapAccountId);
// In the live app the snapshot's tasks come from the calendar store,
// which did not see this change; reload it so the refresh keeps it.
if (ok && before.activeAccountId && singletonServes(before.activeAccountId)) {
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
await useCalendarStore.getState().fetchTasks();
}
} catch (err) {
console.warn('[widgets] task toggle failed', err);
}
await afterServerCall(ok);
return;
}
default:
console.warn(`[widgets] unknown action ${name}`);
}
}
+484
View File
@@ -0,0 +1,484 @@
// Builds the widget snapshot. Mail comes from JMAP on a client of the widgets'
// own (./jmap.ts) and works from a cold headless start. Calendar, tasks,
// birthdays, files and scheduled sends reuse the app's helpers, which are
// bound to the `jmapClient` singleton; they only run when that singleton is
// already connected to the active account (the app's JS runtime is alive).
// Otherwise those parts carry over from the previous snapshot, and the
// time-dependent views are still recomputed when a widget draws.
import AsyncStorage from '@react-native-async-storage/async-storage';
import { jmapClient } from '../api/jmap-client';
import { CAPABILITIES, type CalendarEvent, type ContactCard } from '../api/types';
import { generateAccountId } from '../lib/account-utils';
import { generateEmailAvatarColor, getEmailInitials } from '../lib/avatar-utils';
import { getEventEndDate, getEventStartDate } from '../lib/calendar-utils';
import { getUserParticipantId, getUserStatus } from '../lib/calendar-participants';
import { getBirthday, getContactDisplayName, getDateParts } from '../lib/contact-utils';
import { notificationLocale } from '../lib/push-background-task';
import { useSettingsStore } from '../stores/settings-store';
import { useKeywordsStore, keywordToken } from '../stores/keywords-store';
import { LIGHT_COLORS } from '../theme/tokens';
import { addDays, startOfDay } from './format';
import { startOfWeek } from './derive';
import { fetchInboxPreview, fetchMailSection, openClient, type MailSection } from './jmap';
import { normalizeHex } from './theme';
import {
emptySnapshot,
loadSnapshot,
saveSnapshot,
type AccountSummary,
type Birthday,
type EventItem,
type FileItem,
type Invitation,
type ScheduledItem,
type TagCount,
type TaskItem,
type WidgetSnapshot,
} from './snapshot';
interface RegistryAccount {
id: string;
email?: string;
displayName?: string;
username?: string;
avatarColor?: string;
}
export async function readRegistry(): Promise<{ accounts: RegistryAccount[]; activeAccountId: string | null }> {
try {
const raw = await AsyncStorage.getItem('account-registry');
const state = raw ? (JSON.parse(raw) as { state?: { accounts?: RegistryAccount[]; activeAccountId?: string | null } }).state : null;
const accounts = Array.isArray(state?.accounts) ? state!.accounts! : [];
const active = state?.activeAccountId && accounts.some((a) => a.id === state.activeAccountId)
? state.activeAccountId
: accounts[0]?.id ?? null;
return { accounts, activeAccountId: active };
} catch {
return { accounts: [], activeAccountId: null };
}
}
async function readRecentSearches(): Promise<string[]> {
try {
const raw = await AsyncStorage.getItem('search-history-storage');
const list = raw ? (JSON.parse(raw) as { state?: { recentSearches?: unknown } }).state?.recentSearches : null;
return Array.isArray(list) ? list.filter((s): s is string => typeof s === 'string').slice(0, 8) : [];
} catch {
return [];
}
}
async function readPendingChanges(accountId: string): Promise<number> {
try {
const raw = await AsyncStorage.getItem(`webmail:outbox:v1:${accountId}`);
const list = raw ? JSON.parse(raw) : null;
return Array.isArray(list) ? list.length : 0;
} catch {
return 0;
}
}
/** True when the app's singleton is signed in to `registryAccountId` (live runtime). */
export function singletonServes(registryAccountId: string): boolean {
if (!jmapClient.isConnected) return false;
const { username, serverUrl } = jmapClient;
return !!username && !!serverUrl && generateAccountId(username, serverUrl) === registryAccountId;
}
function tagColor(color: string): string {
const tags = LIGHT_COLORS.tags;
return (color in tags ? tags[color as keyof typeof tags] : tags.gray).dot;
}
function tagCounts(section: MailSection): TagCount[] {
const { keywords } = useKeywordsStore.getState();
const out: TagCount[] = [];
for (const kw of keywords) {
const counts = section.tagCounts.get(keywordToken(kw.id));
if (!counts || counts.total === 0) continue;
out.push({
keyword: keywordToken(kw.id),
name: kw.label,
color: tagColor(kw.color),
unread: counts.unread,
total: counts.total,
...(counts.latest ? { latestFrom: counts.latest.fromName, latestAt: counts.latest.receivedAt } : {}),
});
}
return out.sort((a, b) => b.unread - a.unread || b.total - a.total);
}
// ── App-backed sections (live runtime only) ──────────────────────────────
function participantAddress(p: { email?: string; calendarAddress?: string; sendTo?: Record<string, string> }): string {
const raw = p.email || p.calendarAddress || p.sendTo?.imip || '';
return raw.replace(/^mailto:/i, '');
}
function toEventItem(
event: CalendarEvent,
calendarsById: Map<string, { name: string; color?: string }>,
selfEmails: string[],
): EventItem {
const calId = Object.keys(event.calendarIds ?? {}).find((id) => event.calendarIds[id]);
const cal = calId ? calendarsById.get(calId) : undefined;
const start = getEventStartDate(event).getTime();
const end = Math.max(start, getEventEndDate(event).getTime());
const location = Object.values(event.locations ?? {}).find((l) => l?.name)?.name;
const video = Object.values(event.virtualLocations ?? {}).find((v) => /^https?:\/\//i.test(v?.uri ?? ''));
const self = new Set(selfEmails.map((e) => e.toLowerCase()));
// Other people only: "who is coming" and the running-late mail leave the
// user out.
const participants = Object.values(event.participants ?? {})
.filter((p) => !self.has(participantAddress(p).toLowerCase()))
.map((p) => {
const email = participantAddress(p);
const name = p.name?.trim() || email;
return {
name,
email,
initials: getEmailInitials(p.name ?? '', email) || '?',
color: normalizeHex(generateEmailAvatarColor(p.name ?? '', email)),
};
})
.filter((p) => p.name);
const organizer = Object.values(event.participants ?? {}).find((p) => p.roles?.owner);
const status = getUserStatus(event, selfEmails);
// A timed event from midnight to midnight is a whole-day event in all but
// name (some clients write holidays that way); show it as all-day rather
// than as "00:00 - 00:00".
const wholeDays = !event.showWithoutTime
&& start === startOfDay(start)
&& end > start
&& end === startOfDay(end);
return {
id: event.id,
serverId: event.originalId || event.id,
...(event.accountId ? { jmapAccountId: event.accountId } : {}),
title: event.title || '',
start,
end,
allDay: !!event.showWithoutTime || wholeDays,
color: normalizeHex(event.color || cal?.color),
calendarName: cal?.name ?? '',
...(location ? { location } : {}),
...(video ? { videoUrl: video.uri, videoName: video.name || '' } : {}),
participants,
...(status === 'needs-action' || status === 'accepted' || status === 'tentative' || status === 'declined'
? { myStatus: status }
: {}),
...(organizer ? { organizerName: organizer.name || participantAddress(organizer) } : {}),
};
}
async function buildCalendar(now: number, selfEmails: string[], previous: WidgetSnapshot): Promise<WidgetSnapshot['calendar']> {
const calendarsSupported = jmapClient.hasAccountCapability(CAPABILITIES.CALENDARS);
if (!calendarsSupported) return { supported: false, events: [], invitations: [], birthdays: await buildBirthdays(now) };
const { useCalendarStore, loadEventsInRange } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
// Always refetch: the persisted list can predate a calendar deleted and
// re-created on the server under the same id, which would put events under
// the old name and colour.
try {
await useCalendarStore.getState().fetchCalendars();
} catch {
// fall back to the cached list
}
const { calendars, hiddenCalendarIds } = useCalendarStore.getState();
const visible = calendars.filter((c) => !hiddenCalendarIds.includes(c.id));
const byId = new Map(calendars.map((c) => [c.id, { name: c.name, color: c.color }]));
const first = new Date(now);
first.setDate(1);
const rangeStart = startOfWeek(first.getTime(), useSettingsStore.getState().calendarFirstDayOfWeek === 0 ? 0 : 1);
const rangeEnd = Math.max(addDays(startOfDay(now), 36), addDays(rangeStart, 42));
let raw: CalendarEvent[];
try {
raw = visible.length === 0
? []
: await loadEventsInRange(calendars, visible.map((c) => c.id), new Date(rangeStart).toISOString(), new Date(rangeEnd).toISOString());
} catch (err) {
console.warn('[widgets] calendar refresh failed', err);
return { ...previous.calendar, birthdays: await buildBirthdays(now) };
}
const live = raw.filter((e) => e.status !== 'cancelled');
const events = live
.map((e) => toEventItem(e, byId, selfEmails))
.filter((e) => e.end >= rangeStart && e.start < rangeEnd)
.sort((a, b) => a.start - b.start);
// One entry per series: answering an invitation answers the whole event.
const invitations: Invitation[] = [];
const seen = new Set<string>();
for (const e of live) {
if (getUserStatus(e, selfEmails) !== 'needs-action') continue;
const participantId = getUserParticipantId(e, selfEmails);
const key = e.originalId || e.id;
if (!participantId || seen.has(key)) continue;
const item = toEventItem(e, byId, selfEmails);
if (item.end < now) continue;
seen.add(key);
invitations.push({ ...item, participantId });
}
return {
supported: true,
events,
invitations: invitations.sort((a, b) => a.start - b.start).slice(0, 10),
birthdays: await buildBirthdays(now),
};
}
async function buildBirthdays(now: number): Promise<Birthday[]> {
const { useContactsStore } = require('../stores/contacts-store') as typeof import('../stores/contacts-store');
// The Contacts tab loads the address book lazily; the widget may be first.
if (jmapClient.isConnected) {
try {
await useContactsStore.getState().fetchContactsIfStale();
} catch {
// use whatever is cached
}
}
const contacts: ContactCard[] = useContactsStore.getState().contacts ?? [];
const today = startOfDay(now);
const out: Birthday[] = [];
for (const contact of contacts) {
const date = getBirthday(contact);
if (!date) continue;
const { month, day } = getDateParts(date);
if (!month || !day) continue;
const thisYear = new Date(new Date(today).getFullYear(), month - 1, day).getTime();
const next = thisYear >= today ? thisYear : new Date(new Date(today).getFullYear() + 1, month - 1, day).getTime();
const name = getContactDisplayName(contact);
const email = Object.values(contact.emails ?? {})[0]?.address;
out.push({
name,
...(email ? { email } : {}),
initials: getEmailInitials(name, email) || '?',
color: normalizeHex(generateEmailAvatarColor(name, email)),
date: next,
});
}
return out.sort((a, b) => a.date - b.date).slice(0, 20);
}
async function buildTasks(previous: WidgetSnapshot): Promise<WidgetSnapshot['tasks']> {
if (!useSettingsStore.getState().enableCalendarTasks) return { supported: false, items: [] };
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
try {
const store = useCalendarStore.getState();
if (store.tasks.length === 0) await store.fetchTasks();
const { tasks, calendars } = useCalendarStore.getState();
const byId = new Map(calendars.map((c) => [c.id, c]));
const items: TaskItem[] = tasks
.filter((t) => t.progress !== 'cancelled')
.map((t) => {
const calId = Object.keys(t.calendarIds ?? {}).find((id) => t.calendarIds[id]);
const cal = calId ? byId.get(calId) : undefined;
// A LocalDateTime without an offset parses as device-local time.
const due = t.due ? Date.parse(t.due) : NaN;
return {
id: t.id,
serverId: t.originalId || t.id,
...(t.accountId ? { jmapAccountId: t.accountId } : {}),
title: t.title || '',
...(Number.isFinite(due) ? { due } : {}),
dueHasTime: !!t.due && !t.showWithoutTime && !/T00:00(:00)?$/.test(t.due),
done: t.progress === 'completed',
calendarName: cal?.name ?? '',
color: normalizeHex(t.color || cal?.color),
};
});
return { supported: true, items: items.slice(0, 40) };
} catch {
return previous.tasks;
}
}
async function buildFiles(previous: WidgetSnapshot): Promise<WidgetSnapshot['files']> {
const files = require('../api/files') as typeof import('../api/files');
if (!files.supportsFiles()) return { supported: false, items: [] };
try {
const nodes = await files.getAllFileNodes();
const items: FileItem[] = nodes
.filter((n) => !!n.name)
.map((n) => ({
id: n.id,
name: n.name,
isFolder: files.isFolder(n),
size: n.size ?? 0,
modified: Date.parse(n.modified || n.created || '') || 0,
type: n.type || '',
}))
.sort((a, b) => b.modified - a.modified)
.slice(0, 6);
return { supported: true, items };
} catch {
return previous.files;
}
}
async function buildScheduled(previous: WidgetSnapshot): Promise<ScheduledItem[]> {
try {
if (!jmapClient.hasDelayedSend()) return [];
const { listScheduledEmails } = require('../api/email') as typeof import('../api/email');
const list = await listScheduledEmails();
return list.slice(0, 6).map((s) => {
const to = s.to?.[0];
return {
id: s.emailSubmissionId,
to: to?.name || to?.email || '',
subject: s.subject ?? '',
sendAt: Date.parse(s.sendAt) || 0,
};
});
} catch {
return previous.mail.scheduled;
}
}
// ── Assembly ─────────────────────────────────────────────────────────────
async function buildSnapshot(previous: WidgetSnapshot): Promise<WidgetSnapshot> {
const now = Date.now();
const { accounts, activeAccountId } = await readRegistry();
if (!activeAccountId) return { ...emptySnapshot(), generatedAt: now };
const settings = useSettingsStore.getState();
if (!settings.hydrated) await settings.hydrate();
const keywordsState = useKeywordsStore.getState();
if (!keywordsState.hydrated) await keywordsState.hydrate();
const { theme, timeFormat, calendarFirstDayOfWeek } = useSettingsStore.getState();
const live = singletonServes(activeAccountId);
const client = live ? jmapClient : await openClient(activeAccountId);
const next: WidgetSnapshot = {
...previous,
version: 1,
generatedAt: now,
signedIn: true,
theme,
locale: await notificationLocale(),
hour12: timeFormat === '12h',
weekStart: calendarFirstDayOfWeek === 0 ? 0 : 1,
activeAccountId,
};
if (previous.activeAccountId !== activeAccountId) {
// Nothing of another account's data may linger in a widget.
const blank = emptySnapshot();
next.mail = blank.mail;
next.calendar = blank.calendar;
next.tasks = blank.tasks;
next.files = blank.files;
next.vacation = null;
next.quota = null;
next.appDataAt = 0;
}
if (!client) return next;
const keywordTokens = useKeywordsStore.getState().keywords.map((k) => keywordToken(k.id));
const section = await fetchMailSection(client, activeAccountId, keywordTokens);
const others = accounts.filter((a) => a.id !== activeAccountId);
const previews = await Promise.all(others.map(async (a) => {
try {
const c = await openClient(a.id);
return c ? { account: a, ...(await fetchInboxPreview(c, a.id)) } : null;
} catch {
return null;
}
}));
const inboxUnread = section.folders.find((f) => f.role === 'inbox')?.unread ?? 0;
const summaries: AccountSummary[] = accounts.map((a) => {
const preview = previews.find((p) => p?.account.id === a.id);
return {
id: a.id,
label: a.displayName || a.email || a.username || a.id,
color: normalizeHex(a.avatarColor),
unread: a.id === activeAccountId ? inboxUnread : preview?.unread ?? 0,
};
});
const unified = [
...section.inbox.slice(0, 6),
...previews.flatMap((p) => p?.items ?? []),
].sort((a, b) => b.receivedAt - a.receivedAt).slice(0, 8);
next.accounts = summaries;
next.mail = {
...next.mail,
folders: section.folders,
inbox: section.inbox,
unified,
starred: section.starred,
starredCount: section.starredCount,
drafts: section.drafts,
draftCount: section.draftCount,
favourites: section.favourites,
recentSearches: await readRecentSearches(),
tags: tagCounts(section),
attachments: section.attachments,
pendingChanges: await readPendingChanges(activeAccountId),
};
next.vacation = section.vacation;
next.quota = section.quota;
if (live) {
const [calendar, tasks, files, scheduled] = await Promise.all([
buildCalendar(now, section.selfEmails, next),
buildTasks(next),
buildFiles(next),
buildScheduled(next),
]);
next.calendar = calendar;
next.tasks = tasks;
next.files = files;
next.mail.scheduled = scheduled;
next.appDataAt = now;
}
return next;
}
let inflight: Promise<WidgetSnapshot> | null = null;
let localChanges = 0;
/**
* A widget button just edited the stored snapshot. A refresh that started
* before it read the server before the change, so its result is dropped
* rather than saved over the edit.
*/
export function noteLocalChange(): void {
localChanges++;
}
/**
* Fetch fresh data and store it. Concurrent callers share one run: every
* placed widget gets its own update broadcast, and they would otherwise each
* start a refresh. On failure the previous snapshot is returned unchanged.
*
* `after` is for callers that just changed something on the server (a widget
* button): a run already in flight may have read the server before that
* change, so they wait for it and start (or join) the next one instead.
*/
export async function refreshSnapshot(opts?: { after?: 'change' }): Promise<WidgetSnapshot> {
if (opts?.after === 'change' && inflight) {
await inflight.catch(() => undefined);
return refreshSnapshot();
}
if (inflight) return inflight;
inflight = (async () => {
const changesAtStart = localChanges;
const previous = (await loadSnapshot()) ?? emptySnapshot();
try {
const next = await buildSnapshot(previous);
if (localChanges !== changesAtStart) return (await loadSnapshot()) ?? next;
await saveSnapshot(next);
return next;
} catch (err) {
console.warn('[widgets] refresh failed', err);
return previous;
} finally {
inflight = null;
}
})();
return inflight;
}
+40
View File
@@ -0,0 +1,40 @@
{
"$comment": "Home-screen widgets. scripts/sync-android-widgets.mjs turns this into the Android provider classes, provider XML, labels and manifest receivers; run it after editing. cols/rows are launcher cells (Android 2x2 is roughly the iOS small widget, 4x2 medium, 4x4 large).",
"widgets": [
{ "name": "InboxWidget", "label": "Inbox", "description": "Newest messages with sender, time and subject. Taller sizes add avatars and a line of preview.", "cols": 4, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
{ "name": "TriageWidget", "label": "Triage", "description": "One unread message at a time with archive, delete and reply buttons.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" },
{ "name": "UnreadCountWidget", "label": "Unread count", "description": "Unread count for the inbox, with the latest senders.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
{ "name": "LatestMessageWidget", "label": "Latest message", "description": "Only the newest unread message.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
{ "name": "FolderCountsWidget", "label": "Folder counts", "description": "Unread counts for up to four folders.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
{ "name": "MailShortcutsWidget", "label": "Shortcuts", "description": "Compose, calendar, contacts and files.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail", "static": true },
{ "name": "AllAccountsWidget", "label": "All accounts", "description": "The unified inbox across every signed-in account.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" },
{ "name": "StarredWidget", "label": "Starred", "description": "Starred messages, newest first.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" },
{ "name": "FavouritePeopleWidget", "label": "Favourite people", "description": "The people you hear from most, with their unread count.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" },
{ "name": "MailSearchWidget", "label": "Search mail", "description": "Search bar with your recent searches.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 1, "board": "mail" },
{ "name": "OutboxWidget", "label": "Scheduled and outbox", "description": "Scheduled messages and anything waiting in the outbox.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "mail" },
{ "name": "TagWidget", "label": "Tag", "description": "Unread count for your busiest tag.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
{ "name": "LastDraftWidget", "label": "Last draft", "description": "Back into the draft you left.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "mail" },
{ "name": "AgendaWidget", "label": "Agenda", "description": "Upcoming events by day with time, colour and place.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 2, "board": "calendar" },
{ "name": "WeekWidget", "label": "This week", "description": "The week at a glance from 8:00 to 18:00.", "cols": 4, "rows": 2, "minCols": 4, "minRows": 2, "board": "calendar" },
{ "name": "MonthWidget", "label": "Month", "description": "Month grid with a dot per calendar and the next two events.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 3, "board": "calendar" },
{ "name": "TodayEventsWidget", "label": "Today", "description": "Today's date with the rest of today and tomorrow.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "calendar" },
{ "name": "NextEventWidget", "label": "Next event", "description": "What's next, when and where.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "calendar" },
{ "name": "DateNextWidget", "label": "Date and next two", "description": "Today's date with the next two events.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "calendar" },
{ "name": "UpNextWidget", "label": "Up next", "description": "Countdown to the next event with join and running-late buttons.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "calendar" },
{ "name": "InvitationsWidget", "label": "Invitations", "description": "Pending invitations with accept, maybe and decline.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "plans" },
{ "name": "FreeTimeWidget", "label": "Free time", "description": "When you're free this week.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "plans" },
{ "name": "TasksWidget", "label": "Tasks", "description": "Open tasks with tick boxes that work from the home screen.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 2, "board": "plans" },
{ "name": "TaskProgressWidget", "label": "Task progress", "description": "How many tasks are done.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "plans" },
{ "name": "CountdownWidget", "label": "Countdown", "description": "Days until your next all-day event.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "plans" },
{ "name": "BirthdaysWidget", "label": "Birthdays", "description": "Upcoming birthdays from your contacts.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "plans" },
{ "name": "TodayHubWidget", "label": "Today overview", "description": "Next event, newest unread mail and tasks due, in one widget.", "cols": 4, "rows": 4, "minCols": 3, "minRows": 3, "board": "hubs" },
{ "name": "MailAndNextWidget", "label": "Mail and next event", "description": "Unread mail on one side, the next event on the other.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "hubs" },
{ "name": "RecentFilesWidget", "label": "Recent files", "description": "Recently changed items in Files.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "hubs" },
{ "name": "AttachmentsWidget", "label": "Recent attachments", "description": "The newest attachments from your mail.", "cols": 4, "rows": 2, "minCols": 3, "minRows": 2, "board": "hubs" },
{ "name": "VacationWidget", "label": "Vacation responder", "description": "Vacation responder status with an on/off switch.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "hubs" },
{ "name": "StorageWidget", "label": "Storage", "description": "How much of your mailbox quota is used.", "cols": 2, "rows": 2, "minCols": 2, "minRows": 2, "board": "hubs" }
]
}
+64
View File
@@ -0,0 +1,64 @@
// What a tap on part of a widget does. Either it opens the app on a deep link
// (handled by src/navigation/linking.ts, no JS needed in the widget), or it
// names an action the widget task performs in the background (./actions.ts).
import { APP_SCHEME } from '../navigation/linking';
import type { EventItem, MailItem } from './snapshot';
export interface WidgetClick {
clickAction?: string;
clickActionData?: Record<string, unknown>;
}
export type WidgetActionName =
| 'archive'
| 'trash'
| 'markRead'
| 'triageNext'
| 'rsvp'
| 'toggleTask'
| 'refresh';
export function action(name: WidgetActionName, data: Record<string, unknown> = {}): WidgetClick {
return { clickAction: name, clickActionData: data };
}
export function open(uri: string): WidgetClick {
return { clickAction: 'OPEN_URI', clickActionData: { uri } };
}
const base = `${APP_SCHEME}://`;
const enc = encodeURIComponent;
function query(params: Record<string, string | undefined>): string {
const parts = Object.entries(params)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => `${k}=${enc(v!)}`);
return parts.length ? `?${parts.join('&')}` : '';
}
// Message links carry the thread so the app opens them without asking the
// server first, which also works offline from its cache.
type MessageRef = Pick<MailItem, 'id' | 'accountId' | 'jmapAccountId'> & { threadId?: string };
export const links = {
inbox: () => `${base}mail/folder/inbox`,
message: (m: MessageRef) =>
`${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId, thread: m.threadId })}`,
reply: (m: MessageRef) =>
`${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId, thread: m.threadId, action: 'reply' })}`,
draft: (m: Pick<MailItem, 'id' | 'accountId' | 'jmapAccountId'>) =>
`${base}mail/draft/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId })}`,
unified: (params: { view?: 'all' | 'unread' | 'starred'; role?: string } = {}) =>
`${base}mail/unified${query({ view: params.view, role: params.role })}`,
scheduled: () => `${base}mail/scheduled`,
search: (q = '') => `${base}mail/search${query({ q })}`,
compose: (params: { to?: string; subject?: string; body?: string } = {}) =>
`${base}compose${query({ to: params.to, subject: params.subject, body: params.body })}`,
calendar: () => `${base}calendar`,
event: (e: Pick<EventItem, 'serverId' | 'jmapAccountId'>) =>
`${base}calendar/event/${enc(e.serverId)}${query({ account: e.jmapAccountId })}`,
contacts: () => `${base}contacts`,
files: () => `${base}files`,
settings: (tab?: string) => `${base}settings${tab ? `/${enc(tab)}` : ''}`,
};
+209
View File
@@ -0,0 +1,209 @@
// Views that depend on the current time, computed when a widget draws rather
// than when the snapshot was fetched. Pure functions of (snapshot, now), so
// they are unit-tested without the widget runtime.
import { addDays, startOfDay } from './format';
import type { EventItem, TaskItem, WidgetSnapshot } from './snapshot';
const DAY = 86400000;
/** Events overlapping [dayStart, dayStart + 1 day), all-day first, then by start. */
export function eventsOnDay(events: EventItem[], dayStart: number): EventItem[] {
const dayEnd = addDays(dayStart, 1);
return events
.filter((e) => e.start < dayEnd && (e.end > dayStart || (e.end === e.start && e.start >= dayStart)))
.sort((a, b) => (a.allDay === b.allDay ? a.start - b.start : a.allDay ? -1 : 1));
}
/** Timed events that have not ended yet, soonest first. */
export function upcomingTimed(events: EventItem[], now: number): EventItem[] {
return events.filter((e) => !e.allDay && e.end > now).sort((a, b) => a.start - b.start);
}
/** The next timed event (running or upcoming) and whether it has started. */
export function nextEvent(events: EventItem[], now: number): { event: EventItem; running: boolean } | null {
const [event] = upcomingTimed(events, now);
if (!event) return null;
return { event, running: event.start <= now };
}
export interface AgendaDay {
dayStart: number;
events: EventItem[];
}
/**
* Days from today with at least one event still relevant (today drops the
* events that already ended), up to `maxDays` days with events.
*/
export function agendaDays(events: EventItem[], now: number, maxDays: number, horizonDays = 14): AgendaDay[] {
const out: AgendaDay[] = [];
const today = startOfDay(now);
for (let i = 0; i < horizonDays && out.length < maxDays; i++) {
const day = addDays(today, i);
const list = eventsOnDay(events, day).filter((e) => i > 0 || e.allDay || e.end > now);
if (list.length > 0) out.push({ dayStart: day, events: list });
}
return out;
}
/** Start of the week containing `ms` for the given first weekday (0 Sun, 1 Mon). */
export function startOfWeek(ms: number, weekStart: 0 | 1): number {
const day = startOfDay(ms);
const dow = new Date(day).getDay();
const diff = (dow - weekStart + 7) % 7;
return addDays(day, -diff);
}
export interface MonthCell {
dayStart: number;
day: number;
inMonth: boolean;
isToday: boolean;
/** Distinct calendar colours with events that day, at most three. */
dots: string[];
}
/** The month containing `now` as whole weeks (5 or 6 rows). */
export function monthGrid(events: EventItem[], now: number, weekStart: 0 | 1): MonthCell[][] {
const first = new Date(now);
first.setDate(1);
first.setHours(0, 0, 0, 0);
const month = first.getMonth();
const today = startOfDay(now);
let cursor = startOfWeek(first.getTime(), weekStart);
const weeks: MonthCell[][] = [];
do {
const week: MonthCell[] = [];
for (let i = 0; i < 7; i++) {
const d = new Date(cursor);
const dots: string[] = [];
for (const e of eventsOnDay(events, cursor)) {
if (!dots.includes(e.color)) dots.push(e.color);
if (dots.length === 3) break;
}
week.push({
dayStart: cursor,
day: d.getDate(),
inMonth: d.getMonth() === month,
isToday: cursor === today,
dots,
});
cursor = addDays(cursor, 1);
}
weeks.push(week);
} while (new Date(cursor).getMonth() === month);
return weeks;
}
export interface BusyBlock {
/** Fraction of the visible window, 0..1. */
from: number;
to: number;
color: string;
}
/**
* Timed events of one day clipped to [startHour, endHour) as fractions of that
* window - the week and free-time bars.
*/
export function busyBlocks(events: EventItem[], dayStart: number, startHour = 8, endHour = 18): BusyBlock[] {
const winStart = dayStart + startHour * 3600000;
const winEnd = dayStart + endHour * 3600000;
const span = winEnd - winStart;
return eventsOnDay(events, dayStart)
.filter((e) => !e.allDay && e.end > winStart && e.start < winEnd)
.map((e) => ({
from: (Math.max(e.start, winStart) - winStart) / span,
to: (Math.min(e.end, winEnd) - winStart) / span,
color: e.color,
}));
}
export interface FreeTime {
/** The next timed event today, if any. */
next: EventItem | null;
/** True when an event is running right now. */
busyNow: boolean;
/** Longest free stretch left today inside working hours, if any. */
longestGap: { from: number; to: number } | null;
}
export function freeTime(events: EventItem[], now: number, startHour = 8, endHour = 18): FreeTime {
const today = startOfDay(now);
const winEnd = today + endHour * 3600000;
const timed = eventsOnDay(events, today).filter((e) => !e.allDay);
const busyNow = timed.some((e) => e.start <= now && e.end > now);
const next = timed.find((e) => e.start > now) ?? null;
let cursor = Math.max(now, today + startHour * 3600000);
let longest: { from: number; to: number } | null = null;
for (const e of timed.filter((x) => x.end > cursor).sort((a, b) => a.start - b.start)) {
if (e.start > cursor) {
const gap = { from: cursor, to: Math.min(e.start, winEnd) };
if (gap.to > gap.from && (!longest || gap.to - gap.from > longest.to - longest.from)) longest = gap;
}
cursor = Math.max(cursor, e.end);
if (cursor >= winEnd) break;
}
if (cursor < winEnd) {
const gap = { from: cursor, to: winEnd };
if (!longest || gap.to - gap.from > longest.to - longest.from) longest = gap;
}
return { next, busyNow, longestGap: longest };
}
export type DueKind = 'overdue' | 'today' | 'tomorrow' | 'later' | 'none';
export function dueKind(task: TaskItem, now: number): DueKind {
if (task.due === undefined) return 'none';
const today = startOfDay(now);
const dueDay = startOfDay(task.due);
if (task.dueHasTime ? task.due < now : dueDay < today) return 'overdue';
if (dueDay === today) return 'today';
if (dueDay === addDays(today, 1)) return 'tomorrow';
return 'later';
}
/** Open tasks first (overdue, then by due date, undated last), completed after. */
export function sortTasks(tasks: TaskItem[], now: number): TaskItem[] {
const rank: Record<DueKind, number> = { overdue: 0, today: 1, tomorrow: 2, later: 3, none: 4 };
return [...tasks].sort((a, b) => {
if (a.done !== b.done) return a.done ? 1 : -1;
const ra = rank[dueKind(a, now)];
const rb = rank[dueKind(b, now)];
if (ra !== rb) return ra - rb;
return (a.due ?? Number.MAX_SAFE_INTEGER) - (b.due ?? Number.MAX_SAFE_INTEGER);
});
}
/** Tasks that count for "today": overdue, due today, or completed today-ish. */
export function tasksDueNow(tasks: TaskItem[], now: number): TaskItem[] {
return tasks.filter((t) => {
const kind = dueKind(t, now);
return kind === 'overdue' || kind === 'today';
});
}
/**
* The event a countdown should point at: the next all-day or multi-day event
* that starts after today, else the next timed event on a later day.
*/
export function countdownTarget(events: EventItem[], now: number): EventItem | null {
const tomorrow = addDays(startOfDay(now), 1);
const later = events.filter((e) => e.start >= tomorrow).sort((a, b) => a.start - b.start);
return later.find((e) => e.allDay || e.end - e.start >= DAY) ?? later[0] ?? null;
}
export function daysUntil(ms: number, now: number): number {
return Math.round((startOfDay(ms) - startOfDay(now)) / DAY);
}
/** Birthdays in the next `days` days, soonest first. */
export function upcomingBirthdays(snapshot: WidgetSnapshot, now: number, days = 45) {
const today = startOfDay(now);
const until = addDays(today, days);
return snapshot.calendar.birthdays
.filter((b) => b.date >= today && b.date < until)
.sort((a, b) => a.date - b.date);
}
+132
View File
@@ -0,0 +1,132 @@
// Strings and date formatting for widget layouts. Widgets render in a headless
// task with no React tree, so this uses the plain translate() lookup instead
// of the useTranslation hook. Keys live under `widgets.` in locales/rn/*.json
// (added by `npm run i18n:harvest`); each call repeats the English text as
// the fallback so a missing key never shows up raw on the home screen.
import { isSupportedLocale, translate, type LocaleCode, type MessageParams } from '../i18n';
export interface Fmt {
locale: string;
hour12: boolean;
t: (key: string, fallback: string, params?: MessageParams) => string;
/** Clock time in the user's 12h/24h setting. */
time: (ms: number) => string;
weekdayShort: (ms: number) => string;
weekdayLong: (ms: number) => string;
monthLong: (ms: number) => string;
/** "Sep 28" */
dayMonth: (ms: number) => string;
/** "Mon, Sep 28" */
weekdayDayMonth: (ms: number) => string;
/** "Sep 28, 2026" */
fullDate: (ms: number) => string;
/** Time today, weekday this week, date otherwise - the list's receivedAt style. */
listDate: (ms: number, now: number) => string;
/** "in 25 min", "in 2 h", "now". */
relative: (ms: number, now: number) => string;
bytes: (n: number) => string;
}
function safeFormat(locale: string, options: Intl.DateTimeFormatOptions): (ms: number) => string {
let formatter: Intl.DateTimeFormat | null = null;
try {
formatter = new Intl.DateTimeFormat(locale, options);
} catch {
try {
formatter = new Intl.DateTimeFormat('en', options);
} catch {
formatter = null;
}
}
return (ms: number) => {
const d = new Date(ms);
if (formatter) {
try {
return formatter.format(d);
} catch {
// fall through
}
}
return d.toDateString();
};
}
const pad = (n: number) => String(n).padStart(2, '0');
export function startOfDay(ms: number): number {
const d = new Date(ms);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
export function addDays(ms: number, days: number): number {
const d = new Date(ms);
d.setDate(d.getDate() + days);
return d.getTime();
}
export function sameDay(a: number, b: number): boolean {
return startOfDay(a) === startOfDay(b);
}
export function makeFmt(locale: string, hour12: boolean): Fmt {
const lang: LocaleCode = isSupportedLocale(locale) ? locale : 'en';
const t = (key: string, fallback: string, params?: MessageParams) =>
translate(lang, key, fallback, params);
const weekdayShort = safeFormat(locale, { weekday: 'short' });
const weekdayLong = safeFormat(locale, { weekday: 'long' });
const monthLong = safeFormat(locale, { month: 'long' });
const dayMonth = safeFormat(locale, { month: 'short', day: 'numeric' });
const weekdayDayMonth = safeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric' });
const fullDate = safeFormat(locale, { year: 'numeric', month: 'short', day: 'numeric' });
const time = (ms: number) => {
const d = new Date(ms);
const h = d.getHours();
const m = pad(d.getMinutes());
if (!hour12) return `${pad(h)}:${m}`;
const suffix = h < 12 ? 'AM' : 'PM';
return `${h % 12 === 0 ? 12 : h % 12}:${m} ${suffix}`;
};
return {
locale,
hour12,
t,
time,
weekdayShort,
weekdayLong,
monthLong,
dayMonth,
weekdayDayMonth,
fullDate,
listDate: (ms, now) => {
if (sameDay(ms, now)) return time(ms);
const days = Math.round((startOfDay(now) - startOfDay(ms)) / 86400000);
if (days > 0 && days < 7) return weekdayShort(ms);
return dayMonth(ms);
},
relative: (ms, now) => {
const minutes = Math.round((ms - now) / 60000);
if (minutes <= 0) return t('widgets.relative.now', 'now');
if (minutes < 60) return t('widgets.relative.minutes', 'in {count} min', { count: minutes });
const hours = Math.round(minutes / 60);
if (hours < 24) return t('widgets.relative.hours', 'in {count} h', { count: hours });
const days = Math.round((startOfDay(ms) - startOfDay(now)) / 86400000);
return t('widgets.relative.days', '{count, plural, one {in # day} other {in # days}}', { count: days });
},
bytes: (n) => {
if (!Number.isFinite(n) || n <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
},
};
}
+100
View File
@@ -0,0 +1,100 @@
// Tabler Icons (MIT), the set the webmail draws since it moved off Lucide.
// Path data is copied from @tabler/icons tabler-nodes-outline.json so the
// widgets use the same glyphs as the web UI. Widgets render them through
// SvgWidget, which rasterises an SVG string.
const OUTLINE = {
mail: '<path d="M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10"/><path d="M3 7l9 6l9 -6"/>',
inbox: '<path d="M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12"/><path d="M4 13h3l3 3h4l3 -3h3"/>',
send: '<path d="M10 14l11 -11"/><path d="M21 3l-6.5 18a.55 .55 0 0 1 -1 0l-3.5 -7l-7 -3.5a.55 .55 0 0 1 0 -1l18 -6.5"/>',
file: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/>',
fileText: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/><path d="M9 9l1 0"/><path d="M9 13l6 0"/><path d="M9 17l6 0"/>',
fileSpreadsheet: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"/><path d="M8 11h8v7h-8l0 -7"/><path d="M8 15h8"/><path d="M11 11v7"/>',
presentation: '<path d="M3 4l18 0"/><path d="M4 4v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-10"/><path d="M12 16l0 4"/><path d="M9 20l6 0"/><path d="M8 12l3 -3l2 2l3 -3"/>',
photo: '<path d="M15 8h.01"/><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12"/><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"/><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"/>',
folder: '<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2"/>',
calendarClock: '<path d="M10.5 21h-4.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v3"/><path d="M16 3v4"/><path d="M8 3v4"/><path d="M4 11h10"/><path d="M14 18a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"/><path d="M18 16.5v1.5l.5 .5"/>',
calendar: '<path d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12"/><path d="M16 3v4"/><path d="M8 3v4"/><path d="M4 11h16"/><path d="M11 15h1"/><path d="M12 15v3"/>',
calendarPlain: '<path d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12"/><path d="M16 3v4"/><path d="M8 3v4"/><path d="M4 11h16"/>',
archive: '<path d="M3 6a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2"/><path d="M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-10"/><path d="M10 12l4 0"/>',
trash: '<path d="M4 7l16 0"/><path d="M10 11l0 6"/><path d="M14 11l0 6"/><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"/><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"/>',
ban: '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M5.7 5.7l12.6 12.6"/>',
search: '<path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/>',
edit: '<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1"/><path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415"/><path d="M16 5l3 3"/>',
addressBook: '<path d="M20 6v12a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2"/><path d="M10 16h6"/><path d="M11 11a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M4 8h3"/><path d="M4 12h3"/><path d="M4 16h3"/>',
server: '<path d="M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-2"/><path d="M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2"/><path d="M7 8l0 .01"/><path d="M7 16l0 .01"/><path d="M11 8h6"/><path d="M11 16h6"/>',
paperclip: '<path d="M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"/>',
star: '<path d="M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873l-6.158 -3.245"/>',
message: '<path d="M8 9h8"/><path d="M8 13h6"/><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12"/>',
mapPin: '<path d="M9 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/><path d="M17.657 16.657l-4.243 4.243a2 2 0 0 1 -2.827 0l-4.244 -4.243a8 8 0 1 1 11.314 0"/>',
video: '<path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4"/><path d="M3 8a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2l0 -8"/>',
clock: '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/><path d="M12 7v5l3 3"/>',
check: '<path d="M5 12l5 5l10 -10"/>',
plus: '<path d="M12 5l0 14"/><path d="M5 12l14 0"/>',
chevronLeft: '<path d="M15 6l-6 6l6 6"/>',
chevronRight: '<path d="M9 6l6 6l-6 6"/>',
users: '<path d="M5 7a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"/><path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/><path d="M21 21v-2a4 4 0 0 0 -3 -3.85"/>',
lock: '<path d="M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6"/><path d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0"/><path d="M8 11v-4a4 4 0 1 1 8 0v4"/>',
cake: '<path d="M3 20h18v-8a3 3 0 0 0 -3 -3h-12a3 3 0 0 0 -3 3v8"/><path d="M3 14.803c.312 .135 .654 .204 1 .197a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1c.35 .007 .692 -.062 1 -.197"/><path d="M12 4l1.465 1.638a2 2 0 1 1 -3.015 .099l1.55 -1.737"/>',
beach: '<path d="M17.553 16.75a7.5 7.5 0 0 0 -10.606 0"/><path d="M18 3.804a6 6 0 0 0 -8.196 2.196l10.392 6a6 6 0 0 0 -2.196 -8.196"/><path d="M16.732 10c1.658 -2.87 2.225 -5.644 1.268 -6.196c-.957 -.552 -3.075 1.326 -4.732 4.196"/><path d="M15 9l-3 5.196"/><path d="M3 19.25a2.4 2.4 0 0 1 1 -.25a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 1 .25"/>',
reply: '<path d="M9 14l-4 -4l4 -4"/><path d="M5 10h11a4 4 0 1 1 0 8h-1"/>',
checklist: '<path d="M9.615 20h-2.615a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8"/><path d="M14 19l2 2l4 -4"/><path d="M9 8h4"/><path d="M9 12h2"/>',
upload: '<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"/><path d="M7 9l5 -5l5 5"/><path d="M12 4l0 12"/>',
refresh: '<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4"/><path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"/>',
tag: '<path d="M6.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/><path d="M3 6v5.172a2 2 0 0 0 .586 1.414l7.71 7.71a2.41 2.41 0 0 0 3.408 0l5.592 -5.592a2.41 2.41 0 0 0 0 -3.408l-7.71 -7.71a2 2 0 0 0 -1.414 -.586h-5.172a3 3 0 0 0 -3 3"/>',
} as const;
const FILLED = {
star: '<path d="M8.243 7.34l-6.38 .925l-.113 .023a1 1 0 0 0 -.44 1.684l4.622 4.499l-1.09 6.355l-.013 .11a1 1 0 0 0 1.464 .944l5.706 -3l5.693 3l.1 .046a1 1 0 0 0 1.352 -1.1l-1.091 -6.355l4.624 -4.5l.078 -.085a1 1 0 0 0 -.633 -1.62l-6.38 -.926l-2.852 -5.78a1 1 0 0 0 -1.794 0l-2.853 5.78z"/>',
tag: '<path d="M11.172 2a3 3 0 0 1 2.121 .879l7.71 7.71a3.41 3.41 0 0 1 0 4.822l-5.592 5.592a3.41 3.41 0 0 1 -4.822 0l-7.71 -7.71a3 3 0 0 1 -.879 -2.121v-5.172a4 4 0 0 1 4 -4zm-3.672 3.5a2 2 0 0 0 -1.995 1.85l-.005 .15a2 2 0 1 0 2 -2"/>',
} as const;
export type IconName = keyof typeof OUTLINE;
export type FilledIconName = keyof typeof FILLED;
/** Splits `#rrggbbaa` into an SVG colour plus opacity; rgba() is passed through. */
function paint(color: string): { value: string; opacity: string } {
const m = /^#([0-9a-f]{6})([0-9a-f]{2})$/i.exec(color);
if (m) return { value: `#${m[1]}`, opacity: (parseInt(m[2], 16) / 255).toFixed(3) };
return { value: color, opacity: '1' };
}
/** Outline Tabler icon as an SVG string (24 x 24 viewBox, stroke 2 like the web UI). */
export function icon(name: IconName, color: string, strokeWidth = 2): string {
const { value, opacity } = paint(color);
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" ` +
`stroke="${value}" stroke-opacity="${opacity}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round">` +
`${OUTLINE[name]}</svg>`
);
}
/** Filled Tabler icon (star, tag) as an SVG string. */
export function filledIcon(name: FilledIconName, color: string): string {
const { value, opacity } = paint(color);
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" ` +
`fill="${value}" fill-opacity="${opacity}">${FILLED[name]}</svg>`
);
}
/**
* A progress ring (quota, task progress, countdown). `fraction` is clamped to
* 0..1; the arc starts at 12 o'clock like the webmail's quota ring.
*/
export function ring(fraction: number, track: string, fill: string, strokeWidth = 6): string {
const f = Math.max(0, Math.min(1, Number.isFinite(fraction) ? fraction : 0));
const r = 32 - strokeWidth / 2;
const c = 2 * Math.PI * r;
const t = paint(track);
const p = paint(fill);
const arc = f > 0
? `<circle cx="32" cy="32" r="${r}" fill="none" stroke="${p.value}" stroke-opacity="${p.opacity}" stroke-width="${strokeWidth}" ` +
`stroke-linecap="round" stroke-dasharray="${(f * c).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 32 32)"/>`
: '';
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">` +
`<circle cx="32" cy="32" r="${r}" fill="none" stroke="${t.value}" stroke-opacity="${t.opacity}" stroke-width="${strokeWidth}"/>` +
`${arc}</svg>`
);
}

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